bitlabs-cli-linux-amd64 1.0.8 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GETTING_STARTED.md +44 -0
- package/GO-LIVE.md +19 -0
- package/README.md +42 -0
- package/SECURITY.md +33 -0
- package/SKILL.md +26 -0
- package/TESTING.md +31 -0
- package/bin/bitlabs +0 -0
- package/bin/bitlabs-mcp +0 -0
- package/bin/bitlabs-onboard +0 -0
- package/docs/agent-setup-hosted.md +61 -0
- package/docs/agent-setup.md +24 -0
- package/docs/dashboard-team-handoff.md +66 -0
- package/docs/distribution-release.md +46 -0
- package/docs/onboard-button.html +34 -0
- package/docs/pairing-flow-proposal.md +7 -0
- package/docs/release-v2.0.0.md +13 -0
- package/onboarding/IMPLEMENTATION.md +61 -0
- package/onboarding/START.md +57 -0
- package/onboarding/callbacks.md +11 -0
- package/onboarding/credentials.md +15 -0
- package/onboarding/dashboard-prompt.md +47 -0
- package/onboarding/hosts/node-sqlite.md +17 -0
- package/onboarding/integrations/iframe-node-sqlite-v1.md +12 -0
- package/onboarding/provisioning.md +22 -0
- package/onboarding/verification.md +31 -0
- package/package.json +25 -5
- package/packages/callback-core/README.md +45 -0
- package/packages/callback-core/core.cjs +106 -0
- package/packages/callback-core/embed.go +10 -0
- package/packages/callback-core/handler.cjs +38 -0
- package/packages/callback-core/package.json +9 -0
- package/packages/callback-core/sqlite-wallet.cjs +165 -0
- package/packages/callback-core/test/callback.test.cjs +265 -0
- package/schemas/capabilities.json +86 -0
- package/schemas/evidence.schema.json +110 -0
- package/schemas/result.schema.json +79 -0
- package/schemas/setup.example.json +33 -0
- package/schemas/setup.schema.json +243 -0
- package/site/README.md +34 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
3
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
4
|
+
const { CallbackFailure, validUID, validID } = require('./core.cjs');
|
|
5
|
+
|
|
6
|
+
// Reference adapter for a Node server with durable local disk. Every process must
|
|
7
|
+
// share this database. Ephemeral/serverless disk or multiple independent replicas
|
|
8
|
+
// are unsupported: replace this adapter with the publisher's transactional wallet.
|
|
9
|
+
class SQLiteWallet {
|
|
10
|
+
#db; #app; #environment; #policy; #maxBalance; #scale;
|
|
11
|
+
constructor({ filename, appId, environment, currencyScale, reconciliationPolicy = 'review',
|
|
12
|
+
maxBalanceMinor = 9000000000000000000n }) {
|
|
13
|
+
if (!filename || filename === ':memory:') throw new Error('A persistent SQLite filename is required');
|
|
14
|
+
if (!validID(appId) || !validID(environment)) throw new Error('Explicit app/environment required');
|
|
15
|
+
if (!Number.isInteger(currencyScale) || currencyScale < 0 || currencyScale > 6) throw new Error('Explicit currencyScale from 0 through 6 required');
|
|
16
|
+
if (!['review', 'full-reversal'].includes(reconciliationPolicy)) throw new Error('Unknown reconciliation policy');
|
|
17
|
+
if (typeof maxBalanceMinor !== 'bigint' || maxBalanceMinor <= 0n) throw new Error('Invalid balance bound');
|
|
18
|
+
this.#app = appId; this.#environment = environment; this.#policy = reconciliationPolicy;
|
|
19
|
+
this.#maxBalance = maxBalanceMinor; this.#scale = currencyScale;
|
|
20
|
+
this.#db = new DatabaseSync(filename);
|
|
21
|
+
this.#db.exec(`PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;
|
|
22
|
+
CREATE TABLE IF NOT EXISTS wallet_metadata (
|
|
23
|
+
app TEXT NOT NULL, environment TEXT NOT NULL, currency_scale INTEGER NOT NULL,
|
|
24
|
+
PRIMARY KEY(app, environment));
|
|
25
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
26
|
+
app TEXT NOT NULL, environment TEXT NOT NULL, uid TEXT NOT NULL, balance_minor TEXT NOT NULL,
|
|
27
|
+
PRIMARY KEY(app, environment, uid));
|
|
28
|
+
CREATE TABLE IF NOT EXISTS receipts (
|
|
29
|
+
app TEXT NOT NULL, environment TEXT NOT NULL, namespace TEXT NOT NULL, tx TEXT NOT NULL,
|
|
30
|
+
uid TEXT NOT NULL, fingerprint TEXT NOT NULL, activity TEXT NOT NULL, ref TEXT,
|
|
31
|
+
amount_minor TEXT NOT NULL, currency_scale INTEGER NOT NULL, outcome TEXT NOT NULL, reason TEXT,
|
|
32
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
33
|
+
PRIMARY KEY(app, environment, namespace, tx));
|
|
34
|
+
CREATE TABLE IF NOT EXISTS ledger (
|
|
35
|
+
app TEXT NOT NULL, environment TEXT NOT NULL, tx TEXT NOT NULL, uid TEXT NOT NULL,
|
|
36
|
+
kind TEXT NOT NULL CHECK(kind IN ('CREDIT','REVERSAL')), amount_minor TEXT NOT NULL, ref TEXT,
|
|
37
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
38
|
+
PRIMARY KEY(app, environment, tx),
|
|
39
|
+
FOREIGN KEY(app, environment, uid) REFERENCES accounts(app, environment, uid));
|
|
40
|
+
-- Prevent multiple adjustment transaction IDs from reversing the same credit.
|
|
41
|
+
CREATE UNIQUE INDEX IF NOT EXISTS one_reversal_per_credit ON ledger(app, environment, ref)
|
|
42
|
+
WHERE kind='REVERSAL';
|
|
43
|
+
CREATE TABLE IF NOT EXISTS review_actions (
|
|
44
|
+
id INTEGER PRIMARY KEY, app TEXT NOT NULL, environment TEXT NOT NULL, tx TEXT NOT NULL,
|
|
45
|
+
actor TEXT NOT NULL, outcome TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);
|
|
46
|
+
`);
|
|
47
|
+
try {
|
|
48
|
+
this.#transaction(() => {
|
|
49
|
+
const metadata = this.#db.prepare('SELECT currency_scale FROM wallet_metadata WHERE app=? AND environment=?')
|
|
50
|
+
.get(this.#app, this.#environment);
|
|
51
|
+
if (metadata) {
|
|
52
|
+
if (metadata.currency_scale !== this.#scale) throw new Error('Currency scale change requires an explicit reviewed migration');
|
|
53
|
+
} else {
|
|
54
|
+
const existing = this.#db.prepare('SELECT uid FROM accounts WHERE app=? AND environment=? LIMIT 1')
|
|
55
|
+
.get(this.#app, this.#environment);
|
|
56
|
+
if (existing) throw new Error('Existing wallet without currency metadata requires an explicit reviewed migration');
|
|
57
|
+
this.#db.prepare('INSERT INTO wallet_metadata(app,environment,currency_scale) VALUES(?,?,?)')
|
|
58
|
+
.run(this.#app, this.#environment, this.#scale);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
} catch (error) { this.#db.close(); throw error; }
|
|
62
|
+
}
|
|
63
|
+
#transaction(work) {
|
|
64
|
+
this.#db.exec('BEGIN IMMEDIATE');
|
|
65
|
+
try { const value = work(); this.#db.exec('COMMIT'); return value; }
|
|
66
|
+
catch (error) { this.#db.exec('ROLLBACK'); throw error; }
|
|
67
|
+
}
|
|
68
|
+
#receipt(tx, namespace = 'live') {
|
|
69
|
+
return this.#db.prepare('SELECT * FROM receipts WHERE app=? AND environment=? AND namespace=? AND tx=?')
|
|
70
|
+
.get(this.#app, this.#environment, namespace, tx);
|
|
71
|
+
}
|
|
72
|
+
#account(uid) {
|
|
73
|
+
return this.#db.prepare('SELECT * FROM accounts WHERE app=? AND environment=? AND uid=?')
|
|
74
|
+
.get(this.#app, this.#environment, uid);
|
|
75
|
+
}
|
|
76
|
+
#recordLedger(event, kind, delta) {
|
|
77
|
+
const account = this.#account(event.uid);
|
|
78
|
+
if (!account) throw new CallbackFailure('UNKNOWN_USER', 404);
|
|
79
|
+
const next = BigInt(account.balance_minor) + delta;
|
|
80
|
+
if (next < 0n || next > this.#maxBalance) throw new CallbackFailure('BALANCE_OUT_OF_BOUNDS');
|
|
81
|
+
this.#db.prepare('INSERT INTO ledger(app,environment,tx,uid,kind,amount_minor,ref) VALUES(?,?,?,?,?,?,?)')
|
|
82
|
+
.run(this.#app, this.#environment, event.tx, event.uid, kind, delta.toString(), event.ref);
|
|
83
|
+
this.#db.prepare('UPDATE accounts SET balance_minor=? WHERE app=? AND environment=? AND uid=?')
|
|
84
|
+
.run(next.toString(), this.#app, this.#environment, event.uid);
|
|
85
|
+
}
|
|
86
|
+
#reverse(event) {
|
|
87
|
+
const original = this.#receipt(event.ref);
|
|
88
|
+
if (!original) return { outcome: 'held', applied: false, reason: 'ORIGINAL_NOT_OBSERVED' };
|
|
89
|
+
if (original.activity !== 'COMPLETE' || original.outcome !== 'credited' || original.uid !== event.uid)
|
|
90
|
+
return { outcome: 'held', applied: false, reason: 'ORIGINAL_MISMATCH' };
|
|
91
|
+
if (original.currency_scale !== event.currencyScale || BigInt(original.amount_minor) <= 0n ||
|
|
92
|
+
(event.amountMinor < 0n ? -event.amountMinor : event.amountMinor) !== BigInt(original.amount_minor))
|
|
93
|
+
return { outcome: 'held', applied: false, reason: 'PARTIAL_OR_UNKNOWN_ADJUSTMENT' };
|
|
94
|
+
const reversed = this.#db.prepare("SELECT tx FROM ledger WHERE app=? AND environment=? AND ref=? AND kind='REVERSAL'")
|
|
95
|
+
.get(this.#app, this.#environment, event.ref);
|
|
96
|
+
if (reversed) return { outcome: 'held', applied: false, reason: 'ORIGINAL_ALREADY_REVERSED' };
|
|
97
|
+
const account = this.#account(event.uid);
|
|
98
|
+
if (!account) return { outcome: 'held', applied: false, reason: 'ORIGINAL_ACCOUNT_UNAVAILABLE' };
|
|
99
|
+
if (BigInt(account.balance_minor) < BigInt(original.amount_minor))
|
|
100
|
+
return { outcome: 'held', applied: false, reason: 'INSUFFICIENT_BALANCE' };
|
|
101
|
+
this.#recordLedger(event, 'REVERSAL', -BigInt(original.amount_minor));
|
|
102
|
+
return { outcome: 'reversed', applied: true };
|
|
103
|
+
}
|
|
104
|
+
apply(event) {
|
|
105
|
+
if (event.appId !== this.#app || event.environment !== this.#environment)
|
|
106
|
+
throw new CallbackFailure('APP_ENVIRONMENT_MISMATCH', 409);
|
|
107
|
+
if (event.currencyScale !== this.#scale) throw new CallbackFailure('CURRENCY_SCALE_MISMATCH', 409);
|
|
108
|
+
if (!validUID(event.uid) || !validID(event.tx) || typeof event.amountMinor !== 'bigint' ||
|
|
109
|
+
!['COMPLETE', 'RECONCILIATION'].includes(event.activity) ||
|
|
110
|
+
typeof event.debug !== 'boolean' || !Number.isInteger(event.currencyScale) || event.currencyScale < 0 || event.currencyScale > 6 ||
|
|
111
|
+
(event.activity === 'RECONCILIATION' && (!validID(event.ref) || event.ref === event.tx)) ||
|
|
112
|
+
!/^[a-f0-9]{64}$/.test(event.fingerprint) ||
|
|
113
|
+
(event.activity === 'COMPLETE' && event.amountMinor < 0n)) throw new CallbackFailure('INVALID_EVENT');
|
|
114
|
+
return this.#transaction(() => {
|
|
115
|
+
const namespace = event.debug ? 'debug' : 'live';
|
|
116
|
+
const prior = this.#receipt(event.tx, namespace);
|
|
117
|
+
if (prior) {
|
|
118
|
+
if (prior.fingerprint !== event.fingerprint) throw new CallbackFailure('DUPLICATE_CONTENT_CONFLICT', 409);
|
|
119
|
+
return prior.outcome === 'held' ? { outcome: 'held', applied: false, reason: prior.reason }
|
|
120
|
+
: { outcome: 'duplicate', applied: false };
|
|
121
|
+
}
|
|
122
|
+
if (!event.debug && !this.#account(event.uid)) throw new CallbackFailure('UNKNOWN_USER', 404);
|
|
123
|
+
let result;
|
|
124
|
+
if (event.debug) result = { outcome: 'debug', applied: false };
|
|
125
|
+
else if (event.activity === 'COMPLETE') {
|
|
126
|
+
this.#recordLedger(event, 'CREDIT', event.amountMinor);
|
|
127
|
+
result = { outcome: 'credited', applied: true };
|
|
128
|
+
} else result = this.#policy === 'full-reversal' ? this.#reverse(event)
|
|
129
|
+
: { outcome: 'held', applied: false, reason: 'RECONCILIATION_POLICY_REVIEW' };
|
|
130
|
+
this.#db.prepare(`INSERT INTO receipts(app,environment,namespace,tx,uid,fingerprint,activity,ref,
|
|
131
|
+
amount_minor,currency_scale,outcome,reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
132
|
+
.run(this.#app, this.#environment, namespace, event.tx, event.uid, event.fingerprint, event.activity,
|
|
133
|
+
event.ref, event.amountMinor.toString(), event.currencyScale, result.outcome, result.reason || null);
|
|
134
|
+
return result;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// Called only by the publisher's reviewed administrative workflow, never by HTTP input.
|
|
138
|
+
// Explicitly approves a full reversal of this exact held receipt; no partial policy is inferred.
|
|
139
|
+
resolveHeld(tx, { expectedFingerprint, actor }) {
|
|
140
|
+
if (!validID(actor)) throw new CallbackFailure('REVIEW_ACTOR_REQUIRED');
|
|
141
|
+
return this.#transaction(() => {
|
|
142
|
+
const held = this.#receipt(tx);
|
|
143
|
+
if (!held || held.fingerprint !== expectedFingerprint) throw new CallbackFailure('STALE_REVIEW', 409);
|
|
144
|
+
if (held.outcome !== 'held') return { outcome: 'duplicate', applied: false };
|
|
145
|
+
const result = this.#reverse({ tx, uid: held.uid, ref: held.ref,
|
|
146
|
+
amountMinor: BigInt(held.amount_minor), currencyScale: held.currency_scale });
|
|
147
|
+
this.#db.prepare('UPDATE receipts SET outcome=?,reason=? WHERE app=? AND environment=? AND namespace=? AND tx=?')
|
|
148
|
+
.run(result.outcome, result.reason || null, this.#app, this.#environment, 'live', tx);
|
|
149
|
+
this.#db.prepare('INSERT INTO review_actions(app,environment,tx,actor,outcome,reason) VALUES(?,?,?,?,?,?)')
|
|
150
|
+
.run(this.#app, this.#environment, tx, actor, result.outcome, result.reason || null);
|
|
151
|
+
return result;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// Account creation is a server/admin operation. Never create accounts from callbacks.
|
|
155
|
+
addAccount(uid, initialBalanceMinor = 0n) {
|
|
156
|
+
if (!validUID(uid) || typeof initialBalanceMinor !== 'bigint' || initialBalanceMinor < 0n || initialBalanceMinor > this.#maxBalance)
|
|
157
|
+
throw new Error('Invalid account');
|
|
158
|
+
this.#db.prepare('INSERT INTO accounts(app,environment,uid,balance_minor) VALUES(?,?,?,?)')
|
|
159
|
+
.run(this.#app, this.#environment, uid, initialBalanceMinor.toString());
|
|
160
|
+
}
|
|
161
|
+
getBalance(uid) { const row = this.#account(uid); return row ? BigInt(row.balance_minor) : null; }
|
|
162
|
+
getReceipt(tx, namespace = 'live') { return this.#receipt(tx, namespace) || null; }
|
|
163
|
+
close() { this.#db.close(); }
|
|
164
|
+
}
|
|
165
|
+
module.exports = { SQLiteWallet };
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { test } = require('node:test');
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const { mkdtempSync, rmSync } = require('node:fs');
|
|
5
|
+
const { tmpdir } = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { createHmac } = require('node:crypto');
|
|
8
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
9
|
+
const { Worker } = require('node:worker_threads');
|
|
10
|
+
const { createCallbackHandler } = require('../core.cjs');
|
|
11
|
+
const { SQLiteWallet } = require('../sqlite-wallet.cjs');
|
|
12
|
+
const origin = 'https://rewards.fixture.invalid';
|
|
13
|
+
const secret = 'synthetic-fixture-secret';
|
|
14
|
+
const corePath = path.resolve(__dirname, '../core.cjs');
|
|
15
|
+
const walletPath = path.resolve(__dirname, '../sqlite-wallet.cjs');
|
|
16
|
+
function options(wallet, overrides = {}) {
|
|
17
|
+
return { appId: 'fixture-app', environment: 'staging', secret, publicOrigin: origin,
|
|
18
|
+
callbackPath: '/bitlabs/callback', currencyScale: 2, maxRewardMinor: 1000000n, wallet, ...overrides };
|
|
19
|
+
}
|
|
20
|
+
function signRaw(query, { signingSecret = secret, signingOrigin = origin, targetPath = '/bitlabs/callback' } = {}) {
|
|
21
|
+
const target = targetPath + '?' + query;
|
|
22
|
+
return target + '&hash=' + createHmac('sha1', signingSecret).update(signingOrigin + target).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
function signed(params = {}, signing = {}) {
|
|
25
|
+
return signRaw(new URLSearchParams({ uid: 'user_1', tx: 'event_1', val: '5.25', usd: '1.00',
|
|
26
|
+
activity_type: 'COMPLETE', ...params }).toString(), signing);
|
|
27
|
+
}
|
|
28
|
+
function fixture(t, policy = 'review') {
|
|
29
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'bitlabs-callback-'));
|
|
30
|
+
const filename = path.join(dir, 'wallet.sqlite');
|
|
31
|
+
const wallet = new SQLiteWallet({ filename, appId: 'fixture-app', environment: 'staging', currencyScale: 2, reconciliationPolicy: policy });
|
|
32
|
+
wallet.addAccount('user_1');
|
|
33
|
+
t.after(() => { wallet.close(); rmSync(dir, { recursive: true, force: true }); });
|
|
34
|
+
return { filename, wallet, handle: createCallbackHandler(options(wallet)) };
|
|
35
|
+
}
|
|
36
|
+
function body(response) { return JSON.parse(response.body); }
|
|
37
|
+
|
|
38
|
+
test('valid signed currency value is committed once; missing/invalid signatures fail closed', async t => {
|
|
39
|
+
const { wallet, handle } = fixture(t);
|
|
40
|
+
for (const rawTarget of ['/bitlabs/callback?uid=user_1', signed().replace(/.$/, 'z'),
|
|
41
|
+
signed({}, { signingSecret: 'wrong' }), signed({ debug: 'true' }).split('&hash=')[0]]) {
|
|
42
|
+
const response = await handle({ rawTarget });
|
|
43
|
+
assert.equal(response.status, 403); assert.equal(response.headers['Cache-Control'], 'no-store, max-age=0');
|
|
44
|
+
}
|
|
45
|
+
assert.equal(wallet.getBalance('user_1'), 0n);
|
|
46
|
+
const response = await handle({ rawTarget: signed() });
|
|
47
|
+
assert.deepEqual(body(response), { outcome: 'CREDITED', applied: true });
|
|
48
|
+
assert.equal(wallet.getBalance('user_1'), 525n); // USD is not multiplied into the reward.
|
|
49
|
+
assert.equal(body(await handle({ rawTarget: signed() })).outcome, 'DUPLICATE');
|
|
50
|
+
assert.equal(wallet.getBalance('user_1'), 525n);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('raw encoding is authenticated exactly; duplicate parameters and malformed encoding are rejected', async t => {
|
|
54
|
+
const { wallet, handle } = fixture(t);
|
|
55
|
+
const query = 'uid=user_1&tx=encoded&val=1.25&activity_type=COMPLETE¬e=%2F%20%2b%CE%B1+z';
|
|
56
|
+
assert.equal((await handle({ rawTarget: signRaw(query) })).status, 200);
|
|
57
|
+
assert.equal((await handle({ rawTarget: signRaw(query).replace('%2F', '%2f') })).status, 403);
|
|
58
|
+
assert.equal((await handle({ rawTarget: signRaw(query + '&uid=user_2') })).status, 400);
|
|
59
|
+
assert.equal((await handle({ rawTarget: signRaw(query + '&%75id=user_2') })).status, 400);
|
|
60
|
+
assert.equal((await handle({ rawTarget: signRaw(query + '&hash=extra') })).status, 400);
|
|
61
|
+
for (const encoding of ['%GG', '%FF', '%'])
|
|
62
|
+
assert.equal((await handle({ rawTarget: signRaw(query.replace('encoded', 'bad') + '&bad=' + encoding) })).status, 400);
|
|
63
|
+
assert.equal(wallet.getBalance('user_1'), 125n);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('configured origin and exact path are the only signing authority', async t => {
|
|
67
|
+
const { handle } = fixture(t);
|
|
68
|
+
assert.equal((await handle({ rawTarget: signed({}, { signingOrigin: 'https://attacker.invalid' }) })).status, 403);
|
|
69
|
+
assert.equal((await handle({ rawTarget: signed({}, { targetPath: '/rewritten' }) })).status, 400);
|
|
70
|
+
assert.equal((await handle({ rawTarget: origin + signed() })).status, 400);
|
|
71
|
+
assert.equal((await handle({ rawTarget: signed(), method: 'POST' })).status, 405);
|
|
72
|
+
assert.throws(() => createCallbackHandler(options({}, { publicOrigin: 'https://x.invalid/path' })));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('signed debug creates an isolated persisted receipt without consuming live transaction identities', async t => {
|
|
76
|
+
const { wallet, handle } = fixture(t);
|
|
77
|
+
assert.equal(body(await handle({ rawTarget: signed({ debug: 'true' }) })).outcome, 'DEBUG');
|
|
78
|
+
assert.equal(wallet.getBalance('user_1'), 0n);
|
|
79
|
+
assert.equal(wallet.getReceipt('event_1'), null);
|
|
80
|
+
assert.equal(wallet.getReceipt('event_1', 'debug').outcome, 'debug');
|
|
81
|
+
assert.equal(body(await handle({ rawTarget: signed() })).outcome, 'CREDITED');
|
|
82
|
+
assert.equal(wallet.getBalance('user_1'), 525n);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('unknown users and invalid/missing fields never create accounts or successful receipts', async t => {
|
|
86
|
+
const { wallet, handle } = fixture(t);
|
|
87
|
+
assert.equal((await handle({ rawTarget: signed({ uid: 'missing_user' }) })).status, 404);
|
|
88
|
+
for (const changed of [{ uid: '' }, { uid: 'a'.repeat(66) }, { tx: '' }, { activity_type: 'OTHER' },
|
|
89
|
+
{ debug: '1' }, { val: '' }, { val: 'NaN' }, { val: 'Infinity' }, { val: '1e3' }, { val: '-1' },
|
|
90
|
+
{ val: '0x10' }, { val: '1.001' }, { val: '10000.01' }, { val: '9007199254740993' }]) {
|
|
91
|
+
assert.equal((await handle({ rawTarget: signed(changed) })).status, 422, JSON.stringify(changed));
|
|
92
|
+
}
|
|
93
|
+
assert.equal(wallet.getBalance('missing_user'), null);
|
|
94
|
+
assert.equal(wallet.getBalance('user_1'), 0n);
|
|
95
|
+
assert.equal(wallet.getReceipt('event_1'), null);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('fixed-scale zero and fractional rewards have exact integer balances', async t => {
|
|
99
|
+
const { wallet, handle } = fixture(t);
|
|
100
|
+
for (const [tx, val] of [['zero', '0'], ['decimal1', '0.10'], ['decimal2', '0.20']])
|
|
101
|
+
assert.equal((await handle({ rawTarget: signed({ tx, val }) })).status, 200);
|
|
102
|
+
assert.equal(wallet.getBalance('user_1'), 30n);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a conflicting duplicate never silently replaces a persisted reward', async t => {
|
|
106
|
+
const { wallet, handle } = fixture(t);
|
|
107
|
+
await handle({ rawTarget: signed() });
|
|
108
|
+
const response = await handle({ rawTarget: signed({ val: '7.00' }) });
|
|
109
|
+
assert.equal(response.status, 409); assert.equal(body(response).outcome, 'DUPLICATE_CONTENT_CONFLICT');
|
|
110
|
+
assert.equal(wallet.getBalance('user_1'), 525n);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('duplicate delivery survives another connection and a restart', async t => {
|
|
114
|
+
const { wallet, handle, filename } = fixture(t);
|
|
115
|
+
await handle({ rawTarget: signed() });
|
|
116
|
+
for (let i = 0; i < 2; i++) {
|
|
117
|
+
const another = new SQLiteWallet({ filename, appId: 'fixture-app', environment: 'staging', currencyScale: 2 });
|
|
118
|
+
assert.equal(body(await createCallbackHandler(options(another))({ rawTarget: signed() })).outcome, 'DUPLICATE');
|
|
119
|
+
another.close();
|
|
120
|
+
}
|
|
121
|
+
assert.equal(wallet.getBalance('user_1'), 525n);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('concurrent separate connections credit once under SQLite uniqueness and atomic transactions', async t => {
|
|
125
|
+
const { wallet, filename } = fixture(t);
|
|
126
|
+
const results = await Promise.all(Array.from({ length: 6 }, () => new Promise((resolve, reject) => {
|
|
127
|
+
const worker = new Worker(`
|
|
128
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
129
|
+
const { SQLiteWallet } = require(workerData.walletPath);
|
|
130
|
+
const { createCallbackHandler } = require(workerData.corePath);
|
|
131
|
+
const wallet = new SQLiteWallet({ filename: workerData.filename, appId:'fixture-app', environment:'staging',currencyScale:2 });
|
|
132
|
+
createCallbackHandler({appId:'fixture-app',environment:'staging',secret:workerData.secret,
|
|
133
|
+
publicOrigin:workerData.origin,callbackPath:'/bitlabs/callback',currencyScale:2,maxRewardMinor:1000000n,wallet})
|
|
134
|
+
({rawTarget:workerData.rawTarget}).then(result => { wallet.close(); parentPort.postMessage(result); });
|
|
135
|
+
`, { eval: true, workerData: { filename, walletPath, corePath, secret, origin, rawTarget: signed() } });
|
|
136
|
+
worker.once('message', resolve); worker.once('error', reject);
|
|
137
|
+
worker.once('exit', code => { if (code) reject(new Error('Worker exited ' + code)); });
|
|
138
|
+
})));
|
|
139
|
+
assert.equal(results.filter(result => body(result).outcome === 'CREDITED').length, 1);
|
|
140
|
+
assert.equal(results.filter(result => body(result).outcome === 'DUPLICATE').length, 5);
|
|
141
|
+
assert.equal(wallet.getBalance('user_1'), 525n);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('receipt-write failure rolls back ledger and balance; a later retry succeeds', async t => {
|
|
145
|
+
const { wallet, handle, filename } = fixture(t);
|
|
146
|
+
const db = new DatabaseSync(filename);
|
|
147
|
+
db.exec("CREATE TRIGGER fixture_failure BEFORE INSERT ON receipts BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END;");
|
|
148
|
+
assert.equal((await handle({ rawTarget: signed() })).status, 503);
|
|
149
|
+
assert.equal(wallet.getBalance('user_1'), 0n); assert.equal(wallet.getReceipt('event_1'), null);
|
|
150
|
+
assert.equal(db.prepare('SELECT COUNT(*) n FROM ledger').get().n, 0);
|
|
151
|
+
db.exec('DROP TRIGGER fixture_failure'); db.close();
|
|
152
|
+
assert.equal(body(await handle({ rawTarget: signed() })).outcome, 'CREDITED');
|
|
153
|
+
assert.equal(wallet.getBalance('user_1'), 525n);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('handler awaits durable storage and never acknowledges an adapter failure', async () => {
|
|
157
|
+
let commit;
|
|
158
|
+
const handler = createCallbackHandler(options({ apply: () => new Promise(resolve => { commit = resolve; }) }));
|
|
159
|
+
let finished = false;
|
|
160
|
+
const response = handler({ rawTarget: signed() }).then(r => { finished = true; return r; });
|
|
161
|
+
await new Promise(resolve => setImmediate(resolve)); assert.equal(finished, false);
|
|
162
|
+
commit({ outcome: 'credited', applied: true }); assert.equal((await response).status, 200);
|
|
163
|
+
const broken = createCallbackHandler(options({ apply() { throw new Error('private database details'); } }));
|
|
164
|
+
const result = await broken({ rawTarget: signed() });
|
|
165
|
+
assert.equal(result.status, 503); assert.equal(result.body.includes('private'), false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('default reconciliation policy durably holds a referenced adjustment without changing balances', async t => {
|
|
169
|
+
const { wallet, handle } = fixture(t);
|
|
170
|
+
await handle({ rawTarget: signed() });
|
|
171
|
+
const rawTarget = signed({ tx: 'adjustment', ref: 'event_1', activity_type: 'RECONCILIATION' });
|
|
172
|
+
const result = await handle({ rawTarget });
|
|
173
|
+
assert.deepEqual(body(result), { outcome: 'REVIEW_REQUIRED', applied: false, reason: 'RECONCILIATION_POLICY_REVIEW' });
|
|
174
|
+
assert.equal(result.status, 200); assert.equal(wallet.getReceipt('adjustment').outcome, 'held');
|
|
175
|
+
assert.equal(body(await handle({ rawTarget })).outcome, 'REVIEW_REQUIRED');
|
|
176
|
+
assert.equal(wallet.getBalance('user_1'), 525n);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('explicit full-reversal policy handles positive or negative adjustment values once', async t => {
|
|
180
|
+
const { wallet, handle } = fixture(t, 'full-reversal');
|
|
181
|
+
for (const [tx, val] of [['positive', '5.25'], ['negative', '-5.25']]) {
|
|
182
|
+
await handle({ rawTarget: signed({ tx }) });
|
|
183
|
+
const rawTarget = signed({ tx: tx + '_rev', ref: tx, val, activity_type: 'RECONCILIATION' });
|
|
184
|
+
assert.equal(body(await handle({ rawTarget })).outcome, 'REVERSED');
|
|
185
|
+
assert.equal(body(await handle({ rawTarget })).outcome, 'DUPLICATE');
|
|
186
|
+
assert.equal(wallet.getBalance('user_1'), 0n);
|
|
187
|
+
const again = await handle({ rawTarget: signed({ tx: tx + '_rev2', ref: tx, val, activity_type: 'RECONCILIATION' }) });
|
|
188
|
+
assert.equal(body(again).reason, 'ORIGINAL_ALREADY_REVERSED');
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('partial adjustment, wrong original user and insufficient balance are held', async t => {
|
|
193
|
+
const { wallet, handle, filename } = fixture(t, 'full-reversal');
|
|
194
|
+
await handle({ rawTarget: signed() });
|
|
195
|
+
assert.equal(body(await handle({ rawTarget: signed({ tx: 'partial', val: '2', ref: 'event_1', activity_type: 'RECONCILIATION' }) })).reason,
|
|
196
|
+
'PARTIAL_OR_UNKNOWN_ADJUSTMENT');
|
|
197
|
+
wallet.addAccount('user_2');
|
|
198
|
+
assert.equal(body(await handle({ rawTarget: signed({ uid: 'user_2', tx: 'wrong-user', ref: 'event_1', activity_type: 'RECONCILIATION' }) })).reason,
|
|
199
|
+
'ORIGINAL_MISMATCH');
|
|
200
|
+
const db = new DatabaseSync(filename); // Simulate spending performed by the publisher's wallet.
|
|
201
|
+
db.prepare("UPDATE accounts SET balance_minor='0' WHERE uid='user_1'").run(); db.close();
|
|
202
|
+
assert.equal(body(await handle({ rawTarget: signed({ tx: 'spent', ref: 'event_1', activity_type: 'RECONCILIATION' }) })).reason,
|
|
203
|
+
'INSUFFICIENT_BALANCE');
|
|
204
|
+
assert.equal(wallet.getBalance('user_1'), 0n);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('adjustment before original remains held until an explicit audited resolution', async t => {
|
|
208
|
+
const { wallet, handle, filename } = fixture(t, 'full-reversal');
|
|
209
|
+
const adjustment = signed({ tx: 'early', ref: 'event_1', activity_type: 'RECONCILIATION' });
|
|
210
|
+
assert.equal(body(await handle({ rawTarget: adjustment })).reason, 'ORIGINAL_NOT_OBSERVED');
|
|
211
|
+
await handle({ rawTarget: signed() });
|
|
212
|
+
assert.equal(wallet.getBalance('user_1'), 525n);
|
|
213
|
+
const receipt = wallet.getReceipt('early');
|
|
214
|
+
assert.throws(() => wallet.resolveHeld('early', { expectedFingerprint: 'wrong', actor: 'reviewer_1' }));
|
|
215
|
+
assert.deepEqual(wallet.resolveHeld('early', { expectedFingerprint: receipt.fingerprint, actor: 'reviewer_1' }),
|
|
216
|
+
{ outcome: 'reversed', applied: true });
|
|
217
|
+
assert.equal(wallet.getBalance('user_1'), 0n);
|
|
218
|
+
assert.equal(wallet.resolveHeld('early', { expectedFingerprint: receipt.fingerprint, actor: 'reviewer_1' }).applied, false);
|
|
219
|
+
const db = new DatabaseSync(filename); assert.equal(db.prepare('SELECT COUNT(*) n FROM review_actions').get().n, 1); db.close();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('application and environment namespaces remain separate', async t => {
|
|
223
|
+
const { wallet, handle, filename } = fixture(t);
|
|
224
|
+
await handle({ rawTarget: signed() });
|
|
225
|
+
const production = new SQLiteWallet({ filename, appId: 'fixture-app', environment: 'production', currencyScale: 2 });
|
|
226
|
+
production.addAccount('user_1');
|
|
227
|
+
const productionHandler = createCallbackHandler(options(production, { environment: 'production', secret: 'production-synthetic' }));
|
|
228
|
+
assert.equal((await productionHandler({ rawTarget: signed() })).status, 403);
|
|
229
|
+
assert.equal((await productionHandler({ rawTarget: signed({}, { signingSecret: 'production-synthetic' }) })).status, 200);
|
|
230
|
+
assert.equal(production.getBalance('user_1'), 525n); assert.equal(wallet.getBalance('user_1'), 525n);
|
|
231
|
+
const mismatch = createCallbackHandler(options(wallet, { environment: 'production' }));
|
|
232
|
+
assert.equal((await mismatch({ rawTarget: signed() })).status, 409);
|
|
233
|
+
production.close();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('official historical HMAC example authenticates without URL normalization', async () => {
|
|
237
|
+
// Public documentation fixture, not a production credential. Verified 2026-09-22:
|
|
238
|
+
// https://developer.bitlabs.ai/docs/securing-callbacks-through-hashing
|
|
239
|
+
const handler = createCallbackHandler(options({ apply() { throw new Error('incomplete legacy payload'); } }, {
|
|
240
|
+
publicOrigin: 'https://publisher.com', callbackPath: '/complete', secret: 'JLOIAUNMHFli7ZJOQVEzm98rzqnm9',
|
|
241
|
+
}));
|
|
242
|
+
const response = await handler({ rawTarget: '/complete?uid=8cc877ee-af19-488d-b28d-216fb866b996&val=500&hash=dbcd6bb8ca677344592842a52b4fca9bec36cd4b' });
|
|
243
|
+
// Signature is valid. The old docs example omits our required transaction/activity fields.
|
|
244
|
+
assert.equal(response.status, 422); assert.equal(body(response).outcome, 'INVALID_TRANSACTION_ID');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('wallet currency scale is bound durably and cannot silently change on another deployment', async t => {
|
|
248
|
+
const { wallet, handle, filename } = fixture(t);
|
|
249
|
+
await handle({ rawTarget: signed() });
|
|
250
|
+
assert.throws(() => new SQLiteWallet({ filename, appId: 'fixture-app', environment: 'staging', currencyScale: 0 }),
|
|
251
|
+
/explicit reviewed migration/);
|
|
252
|
+
const changed = createCallbackHandler(options(wallet, { currencyScale: 0 }));
|
|
253
|
+
const response = await changed({ rawTarget: signed({ tx: 'changed-scale', val: '5' }) });
|
|
254
|
+
assert.equal(response.status, 409); assert.equal(body(response).outcome, 'CURRENCY_SCALE_MISMATCH');
|
|
255
|
+
assert.equal(wallet.getBalance('user_1'), 525n); assert.equal(wallet.getReceipt('changed-scale'), null);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('adapter-provided reasons and custom errors cannot expose arbitrary runtime data', async () => {
|
|
259
|
+
const handler = createCallbackHandler(options({ apply: () => ({ outcome: 'held', applied: false, reason: 'secret or raw callback URL' }) }));
|
|
260
|
+
const response = await handler({ rawTarget: signed() });
|
|
261
|
+
assert.equal(body(response).reason, 'REVIEW_REQUIRED'); assert.equal(response.body.includes('secret'), false);
|
|
262
|
+
const { CallbackFailure } = require('../core.cjs');
|
|
263
|
+
const failed = createCallbackHandler(options({ apply: () => { throw new CallbackFailure('private runtime detail', 422); } }));
|
|
264
|
+
assert.equal(body(await failed({ rawTarget: signed() })).outcome, 'TEMPORARY_STORAGE_FAILURE');
|
|
265
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "1",
|
|
3
|
+
"release_status": "unreleased",
|
|
4
|
+
"operations": [
|
|
5
|
+
{
|
|
6
|
+
"id": "management.app.read",
|
|
7
|
+
"documented": true,
|
|
8
|
+
"enabled": "pinned-helper-staging",
|
|
9
|
+
"live_contract_verified": false,
|
|
10
|
+
"last_live_verification": null,
|
|
11
|
+
"fallback": "Read selected app in dashboard"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "management.app.create",
|
|
15
|
+
"documented": true,
|
|
16
|
+
"enabled": "pinned-helper-staging",
|
|
17
|
+
"live_contract_verified": false,
|
|
18
|
+
"last_live_verification": null,
|
|
19
|
+
"fallback": "Create app in dashboard"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "management.safe-config.patch",
|
|
23
|
+
"documented": true,
|
|
24
|
+
"enabled": "pinned-helper-staging",
|
|
25
|
+
"live_contract_verified": false,
|
|
26
|
+
"last_live_verification": null,
|
|
27
|
+
"fallback": "Configure reviewed fields in dashboard"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "management.currency.patch",
|
|
31
|
+
"documented": true,
|
|
32
|
+
"enabled": false,
|
|
33
|
+
"live_contract_verified": false,
|
|
34
|
+
"last_live_verification": null,
|
|
35
|
+
"fallback": "Review base conversion, reward share and preview in dashboard"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "management.app-token.retrieve",
|
|
39
|
+
"documented": false,
|
|
40
|
+
"enabled": false,
|
|
41
|
+
"live_contract_verified": false,
|
|
42
|
+
"last_live_verification": null,
|
|
43
|
+
"fallback": "Copy public App/API Token from dashboard into public client configuration"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "callbacks.register",
|
|
47
|
+
"documented": false,
|
|
48
|
+
"enabled": false,
|
|
49
|
+
"live_contract_verified": false,
|
|
50
|
+
"last_live_verification": null,
|
|
51
|
+
"fallback": "Save general reward callback in dashboard"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "callbacks.test-url",
|
|
55
|
+
"documented": true,
|
|
56
|
+
"enabled": false,
|
|
57
|
+
"live_contract_verified": false,
|
|
58
|
+
"last_live_verification": null,
|
|
59
|
+
"fallback": "Use dashboard Callback Tester; nested API payload, permissions and debug behavior require live validation"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "callbacks.perform-test",
|
|
63
|
+
"documented": false,
|
|
64
|
+
"enabled": false,
|
|
65
|
+
"live_contract_verified": false,
|
|
66
|
+
"last_live_verification": null,
|
|
67
|
+
"fallback": "Use dashboard Callback Tester"
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"recipes": [
|
|
71
|
+
{
|
|
72
|
+
"id": "iframe-node-sqlite-v1",
|
|
73
|
+
"implementation": "local-reference",
|
|
74
|
+
"provider_delivery_verified": false,
|
|
75
|
+
"production_certified": false,
|
|
76
|
+
"host": "Node with persistent local disk; not ephemeral/serverless storage",
|
|
77
|
+
"wallet": "SQLite reference or separately certified existing wallet adapter",
|
|
78
|
+
"deferred": [
|
|
79
|
+
"user-based API",
|
|
80
|
+
"native SDKs",
|
|
81
|
+
"serverless SQLite",
|
|
82
|
+
"additional hosts"
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "object",
|
|
3
|
+
"additionalProperties": false,
|
|
4
|
+
"properties": {
|
|
5
|
+
"schema_version": {
|
|
6
|
+
"const": "1"
|
|
7
|
+
},
|
|
8
|
+
"setup_id": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"pattern": "^(?![Bb][Ll][Pp][Uu][Bb]_)[A-Za-z0-9][A-Za-z0-9_-]{0,79}$"
|
|
11
|
+
},
|
|
12
|
+
"plan_sha256": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"pattern": "^[a-f0-9]{64}$"
|
|
15
|
+
},
|
|
16
|
+
"app_id": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"pattern": "^(?![Bb][Ll][Pp][Uu][Bb]_)[A-Za-z0-9][A-Za-z0-9_-]{0,79}$"
|
|
19
|
+
},
|
|
20
|
+
"environment": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"enum": [
|
|
23
|
+
"staging",
|
|
24
|
+
"production"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"recipe": {
|
|
28
|
+
"const": "iframe-node-sqlite-v1"
|
|
29
|
+
},
|
|
30
|
+
"implementation_revision": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"pattern": "^[a-f0-9]{40}$"
|
|
33
|
+
},
|
|
34
|
+
"checks": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"maxItems": 9,
|
|
37
|
+
"items": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"additionalProperties": false,
|
|
40
|
+
"properties": {
|
|
41
|
+
"id": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"enum": [
|
|
44
|
+
"client_build",
|
|
45
|
+
"signature_fixtures",
|
|
46
|
+
"wallet_transactions",
|
|
47
|
+
"reconciliation",
|
|
48
|
+
"debug_isolation",
|
|
49
|
+
"callback_registered",
|
|
50
|
+
"signed_callback_observed",
|
|
51
|
+
"wallet_test_isolation",
|
|
52
|
+
"production_approval"
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"status": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"enum": [
|
|
58
|
+
"PASS",
|
|
59
|
+
"FAIL",
|
|
60
|
+
"NOT_RUN",
|
|
61
|
+
"HUMAN_CONFIRMED"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"source": {
|
|
65
|
+
"type": "string",
|
|
66
|
+
"enum": [
|
|
67
|
+
"local_test",
|
|
68
|
+
"server_receipt",
|
|
69
|
+
"human"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"observed_at": {
|
|
73
|
+
"type": "string"
|
|
74
|
+
},
|
|
75
|
+
"implementation_revision": {
|
|
76
|
+
"type": "string"
|
|
77
|
+
},
|
|
78
|
+
"artifact_sha256": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"pattern": "^[a-f0-9]{64}$"
|
|
81
|
+
},
|
|
82
|
+
"note": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"maxLength": 300
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"required": [
|
|
88
|
+
"id",
|
|
89
|
+
"status",
|
|
90
|
+
"source",
|
|
91
|
+
"observed_at",
|
|
92
|
+
"implementation_revision"
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"required": [
|
|
98
|
+
"schema_version",
|
|
99
|
+
"setup_id",
|
|
100
|
+
"plan_sha256",
|
|
101
|
+
"app_id",
|
|
102
|
+
"environment",
|
|
103
|
+
"recipe",
|
|
104
|
+
"implementation_revision",
|
|
105
|
+
"checks"
|
|
106
|
+
],
|
|
107
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
108
|
+
"title": "BitLabs reported completion evidence v1",
|
|
109
|
+
"description": "Imported evidence is not an authenticated attestation. CLI enforces source/status/age/revision binding."
|
|
110
|
+
}
|