gateway-admin-billing-repair.test.mjs
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
9 days ago
| 1 | /** |
| 2 | * Tests for POST /api/v1/admin/billing/repair |
| 3 | * |
| 4 | * 7 tiers: unit → integration → e2e → stress → data-integrity → performance → security |
| 5 | * |
| 6 | * The endpoint writes directly to the billing DB to repair missed Stripe webhook events. |
| 7 | * Auth: admin JWT (sub must be in HUB_ADMIN_USER_IDS env var). Non-admins get 403. |
| 8 | * |
| 9 | * SEC-KN-3a decision: do **not** skip/gate on a canister replica. This suite already sets |
| 10 | * CANISTER_URL='' and never calls the canister. The plain-`npm test` hang was a corrupt |
| 11 | * shared `data/hosted_billing.json` plus leftover HTTP handles — fixed by isolating the |
| 12 | * billing DB via KNOWTATION_BILLING_DB_PATH (see createGateway). |
| 13 | */ |
| 14 | import { describe, it, before, after } from 'node:test'; |
| 15 | import assert from 'node:assert/strict'; |
| 16 | import http from 'node:http'; |
| 17 | import crypto from 'node:crypto'; |
| 18 | import fs from 'node:fs'; |
| 19 | import os from 'node:os'; |
| 20 | import path from 'node:path'; |
| 21 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 22 | |
| 23 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 24 | const ROOT = path.resolve(__dirname, '..'); |
| 25 | const SERVER_SRC = fs.readFileSync(path.join(ROOT, 'hub', 'gateway', 'server.mjs'), 'utf8'); |
| 26 | |
| 27 | // ─── Shared test identities ─────────────────────────────────────────────────── |
| 28 | const SECRET = 'admin-billing-repair-test-secret-32c'; |
| 29 | const ADMIN_SUB = 'google:admin-billing-test-00001'; |
| 30 | const MEMBER_SUB = 'google:member-billing-test-00002'; |
| 31 | const OTHER_SUB = 'google:other-billing-test-00003'; |
| 32 | |
| 33 | // ─── JWT helpers (manual HMAC-SHA256, same as C7 tests) ────────────────────── |
| 34 | function makeJwt(sub, role = 'member', secret = SECRET) { |
| 35 | const now = Math.floor(Date.now() / 1000); |
| 36 | const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); |
| 37 | const payload = Buffer.from(JSON.stringify({ |
| 38 | sub, role, provider: 'google', id: sub, iat: now - 5, exp: now + 3600, |
| 39 | })).toString('base64url'); |
| 40 | const sig = crypto.createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url'); |
| 41 | return `${header}.${payload}.${sig}`; |
| 42 | } |
| 43 | |
| 44 | function adminToken() { return makeJwt(ADMIN_SUB, 'admin'); } |
| 45 | function memberToken() { return makeJwt(MEMBER_SUB, 'member'); } |
| 46 | |
| 47 | // ─── Server helpers ─────────────────────────────────────────────────────────── |
| 48 | function startServer(app) { |
| 49 | const srv = http.createServer(app); |
| 50 | return new Promise((resolve, reject) => { |
| 51 | srv.listen(0, '127.0.0.1', (err) => { |
| 52 | if (err) return reject(err); |
| 53 | resolve({ |
| 54 | url: `http://127.0.0.1:${srv.address().port}`, |
| 55 | close: () => new Promise((r) => { |
| 56 | // closeAllConnections() destroys keepalive connections so srv.close() can finish. |
| 57 | if (typeof srv.closeAllConnections === 'function') srv.closeAllConnections(); |
| 58 | srv.close(() => r()); |
| 59 | }), |
| 60 | }); |
| 61 | }); |
| 62 | }); |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Each call gets a fresh module instance (cache-busting query string) and an |
| 67 | * isolated billing DB file so concurrent / prior suite runs cannot share a |
| 68 | * corrupt repo-local data/hosted_billing.json. |
| 69 | */ |
| 70 | async function createGateway() { |
| 71 | const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kn-billing-repair-')); |
| 72 | process.env.SESSION_SECRET = SECRET; |
| 73 | process.env.HUB_ADMIN_USER_IDS = ADMIN_SUB; |
| 74 | process.env.BILLING_ENFORCE = 'false'; |
| 75 | process.env.NETLIFY = '1'; |
| 76 | process.env.CANISTER_URL = ''; |
| 77 | process.env.BRIDGE_URL = ''; |
| 78 | process.env.STRIPE_SECRET_KEY = ''; // not needed for repair endpoint |
| 79 | process.env.KNOWTATION_BILLING_DB_PATH = path.join(tmpDir, 'hosted_billing.json'); |
| 80 | // Ensure file-backed store (billing-store prefers blob when this global is set). |
| 81 | delete globalThis.__knowtation_gateway_blob; |
| 82 | const entry = pathToFileURL(path.join(ROOT, 'hub', 'gateway', 'server.mjs')).href; |
| 83 | const { app } = await import(`${entry}?repair-test=${Date.now()}-${Math.random()}`); |
| 84 | const srv = await startServer(app); |
| 85 | return { |
| 86 | ...srv, |
| 87 | close: async () => { |
| 88 | await srv.close(); |
| 89 | try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} |
| 90 | }, |
| 91 | }; |
| 92 | } |
| 93 | |
| 94 | // ─── HTTP helpers ───────────────────────────────────────────────────────────── |
| 95 | async function post(baseUrl, path_, body, token) { |
| 96 | const raw = JSON.stringify(body); |
| 97 | return new Promise((resolve, reject) => { |
| 98 | const u = new URL(baseUrl + path_); |
| 99 | const req = http.request({ |
| 100 | hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', |
| 101 | headers: { |
| 102 | 'Content-Type': 'application/json', |
| 103 | 'Content-Length': Buffer.byteLength(raw), |
| 104 | ...(token ? { Authorization: `Bearer ${token}` } : {}), |
| 105 | }, |
| 106 | }, (res) => { |
| 107 | let data = ''; |
| 108 | res.on('data', (c) => { data += c; }); |
| 109 | res.on('end', () => { |
| 110 | try { resolve({ status: res.statusCode, body: JSON.parse(data), headers: res.headers }); } |
| 111 | catch { resolve({ status: res.statusCode, body: data, headers: res.headers }); } |
| 112 | }); |
| 113 | }); |
| 114 | req.on('error', reject); |
| 115 | req.write(raw); |
| 116 | req.end(); |
| 117 | }); |
| 118 | } |
| 119 | |
| 120 | /** Same as post() but sends Connection: close — prevents keep-alive socket reuse in stress tests. */ |
| 121 | async function postClose(baseUrl, path_, body, token) { |
| 122 | const raw = JSON.stringify(body); |
| 123 | return new Promise((resolve, reject) => { |
| 124 | const u = new URL(baseUrl + path_); |
| 125 | const req = http.request({ |
| 126 | hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', |
| 127 | headers: { |
| 128 | 'Content-Type': 'application/json', |
| 129 | 'Content-Length': Buffer.byteLength(raw), |
| 130 | 'Connection': 'close', |
| 131 | ...(token ? { Authorization: `Bearer ${token}` } : {}), |
| 132 | }, |
| 133 | }, (res) => { |
| 134 | let data = ''; |
| 135 | res.on('data', (c) => { data += c; }); |
| 136 | res.on('end', () => { |
| 137 | try { resolve({ status: res.statusCode, body: JSON.parse(data) }); } |
| 138 | catch { resolve({ status: res.statusCode, body: data }); } |
| 139 | }); |
| 140 | }); |
| 141 | req.on('error', reject); |
| 142 | req.write(raw); |
| 143 | req.end(); |
| 144 | }); |
| 145 | } |
| 146 | |
| 147 | async function get(baseUrl, path_, token) { |
| 148 | return new Promise((resolve, reject) => { |
| 149 | const u = new URL(baseUrl + path_); |
| 150 | const req = http.request({ |
| 151 | hostname: u.hostname, port: u.port, path: u.pathname, method: 'GET', |
| 152 | headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, |
| 153 | }, (res) => { |
| 154 | let data = ''; |
| 155 | res.on('data', (c) => { data += c; }); |
| 156 | res.on('end', () => { |
| 157 | try { resolve({ status: res.statusCode, body: JSON.parse(data) }); } |
| 158 | catch { resolve({ status: res.statusCode, body: data }); } |
| 159 | }); |
| 160 | }); |
| 161 | req.on('error', reject); |
| 162 | req.end(); |
| 163 | }); |
| 164 | } |
| 165 | |
| 166 | const REPAIR = '/api/v1/admin/billing/repair'; |
| 167 | const SUMMARY = '/api/v1/billing/summary'; |
| 168 | |
| 169 | // ─── 1. Unit: structural wiring ─────────────────────────────────────────────── |
| 170 | |
| 171 | describe('admin/billing/repair — unit: structural wiring', () => { |
| 172 | it('endpoint is declared in server.mjs', () => { |
| 173 | assert.ok( |
| 174 | SERVER_SRC.includes("'/api/v1/admin/billing/repair'"), |
| 175 | 'route must be mounted in server.mjs', |
| 176 | ); |
| 177 | }); |
| 178 | |
| 179 | it('MONTHLY_INCLUDED_CENTS_BY_TIER is imported in server.mjs', () => { |
| 180 | assert.ok( |
| 181 | SERVER_SRC.includes('MONTHLY_INCLUDED_CENTS_BY_TIER'), |
| 182 | 'must import MONTHLY_INCLUDED_CENTS_BY_TIER from billing-constants', |
| 183 | ); |
| 184 | }); |
| 185 | |
| 186 | it('VALID_REPAIR_TIERS set is declared in server.mjs', () => { |
| 187 | assert.ok(SERVER_SRC.includes('VALID_REPAIR_TIERS'), 'tier allowlist must exist'); |
| 188 | }); |
| 189 | }); |
| 190 | |
| 191 | // ─── 2. Integration: DB mutation ────────────────────────────────────────────── |
| 192 | |
| 193 | describe('admin/billing/repair — integration: DB mutation', () => { |
| 194 | let gw; |
| 195 | before(async () => { gw = await createGateway(); }); |
| 196 | after(() => gw.close()); |
| 197 | |
| 198 | it('returns ok:true with uid, tier, and before snapshot', async () => { |
| 199 | const { status, body } = await post(gw.url, REPAIR, { tier: 'plus' }, adminToken()); |
| 200 | assert.equal(status, 200); |
| 201 | assert.equal(body.ok, true); |
| 202 | assert.equal(body.tier, 'plus'); |
| 203 | assert.equal(typeof body.uid, 'string'); |
| 204 | assert.ok('before' in body, 'before snapshot required'); |
| 205 | }); |
| 206 | |
| 207 | it('defaults uid to the calling admin when uid is omitted', async () => { |
| 208 | const { body } = await post(gw.url, REPAIR, { tier: 'growth' }, adminToken()); |
| 209 | assert.equal(body.uid, ADMIN_SUB); |
| 210 | }); |
| 211 | |
| 212 | it('accepts an explicit uid different from the caller', async () => { |
| 213 | const { body } = await post(gw.url, REPAIR, { uid: OTHER_SUB, tier: 'plus' }, adminToken()); |
| 214 | assert.equal(body.uid, OTHER_SUB); |
| 215 | }); |
| 216 | |
| 217 | it('tier change is reflected in billing/summary', async () => { |
| 218 | await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'plus' }, adminToken()); |
| 219 | const { body } = await get(gw.url, SUMMARY, adminToken()); |
| 220 | assert.equal(body.tier, 'plus'); |
| 221 | }); |
| 222 | |
| 223 | it('setting stripe_subscription_id → has_active_subscription:true in summary', async () => { |
| 224 | await post(gw.url, REPAIR, |
| 225 | { uid: ADMIN_SUB, tier: 'plus', stripe_subscription_id: 'sub_integration_test' }, adminToken()); |
| 226 | const { body } = await get(gw.url, SUMMARY, adminToken()); |
| 227 | assert.equal(body.has_active_subscription, true); |
| 228 | }); |
| 229 | |
| 230 | it('clearing stripe_subscription_id (empty string) → has_active_subscription:false', async () => { |
| 231 | await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'plus', stripe_subscription_id: 'sub_will_clear' }, adminToken()); |
| 232 | await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'plus', stripe_subscription_id: '' }, adminToken()); |
| 233 | const { body } = await get(gw.url, SUMMARY, adminToken()); |
| 234 | assert.equal(body.has_active_subscription, false); |
| 235 | }); |
| 236 | |
| 237 | it('omitting stripe_subscription_id leaves existing value intact', async () => { |
| 238 | await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'plus', stripe_subscription_id: 'sub_preserved' }, adminToken()); |
| 239 | await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'growth' }, adminToken()); // no sub field |
| 240 | const { body } = await get(gw.url, SUMMARY, adminToken()); |
| 241 | assert.equal(body.has_active_subscription, true, 'sub id should survive tier-only repair'); |
| 242 | }); |
| 243 | |
| 244 | it('before snapshot reflects the previous tier', async () => { |
| 245 | await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'free' }, adminToken()); |
| 246 | const { body } = await post(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'pro' }, adminToken()); |
| 247 | assert.equal(body.before.tier, 'free'); |
| 248 | assert.equal(body.tier, 'pro'); |
| 249 | }); |
| 250 | |
| 251 | it('accepts all valid tier names', async () => { |
| 252 | const tiers = ['free', 'beta', 'plus', 'growth', 'pro', 'starter', 'team']; |
| 253 | for (const tier of tiers) { |
| 254 | const { status } = await post(gw.url, REPAIR, { tier }, adminToken()); |
| 255 | assert.equal(status, 200, `tier "${tier}" must be accepted`); |
| 256 | } |
| 257 | }); |
| 258 | }); |
| 259 | |
| 260 | // ─── 3. End-to-end: full pack-visibility repair scenario ────────────────────── |
| 261 | |
| 262 | describe('admin/billing/repair — e2e: pack-visibility repair', () => { |
| 263 | let gw; |
| 264 | before(async () => { gw = await createGateway(); }); |
| 265 | after(() => gw.close()); |
| 266 | |
| 267 | it('user starts at beta → admin repairs to plus+sub → both gates fixed', async () => { |
| 268 | // Verify initial state (fresh gateway starts users at beta/default) |
| 269 | const initial = await get(gw.url, SUMMARY, adminToken()); |
| 270 | assert.notEqual(initial.body.tier, 'plus', 'should not already be plus before repair'); |
| 271 | |
| 272 | // Perform repair |
| 273 | const repair = await post(gw.url, REPAIR, |
| 274 | { uid: ADMIN_SUB, tier: 'plus', stripe_subscription_id: 'sub_e2e_repair' }, adminToken()); |
| 275 | assert.equal(repair.status, 200); |
| 276 | assert.equal(repair.body.ok, true); |
| 277 | |
| 278 | // Verify both pack gates are now satisfied |
| 279 | const after = await get(gw.url, SUMMARY, adminToken()); |
| 280 | assert.equal(after.body.tier, 'plus'); |
| 281 | assert.equal(after.body.has_active_subscription, true); |
| 282 | // stripe_configured depends on STRIPE_SECRET_KEY env, which we blanked for tests. |
| 283 | // The other two gates (tier != beta/free, has_active_subscription) are now fixed. |
| 284 | }); |
| 285 | }); |
| 286 | |
| 287 | // ─── 4. Stress: rapid concurrent repairs ───────────────────────────────────── |
| 288 | |
| 289 | describe('admin/billing/repair — stress: concurrent repairs', () => { |
| 290 | let gw; |
| 291 | before(async () => { gw = await createGateway(); }); |
| 292 | after(() => gw.close()); |
| 293 | |
| 294 | it('5 concurrent repair calls all succeed without throwing', async () => { |
| 295 | // Use connection:close on each request so the HTTP agent does not queue |
| 296 | // requests behind a shared keep-alive socket, which prevents the server |
| 297 | // from closing cleanly when there are concurrent file writes. |
| 298 | const calls = Array.from({ length: 5 }, () => |
| 299 | postClose(gw.url, REPAIR, { uid: ADMIN_SUB, tier: 'plus' }, adminToken()), |
| 300 | ); |
| 301 | const results = await Promise.allSettled(calls); |
| 302 | for (const r of results) { |
| 303 | assert.equal(r.status, 'fulfilled', 'call must resolve, not reject'); |
| 304 | assert.equal(r.value.status, 200, 'all concurrent repair calls should succeed'); |
| 305 | } |
| 306 | }); |
| 307 | }); |
| 308 | |
| 309 | // ─── 5. Data integrity ──────────────────────────────────────────────────────── |
| 310 | |
| 311 | describe('admin/billing/repair — data-integrity', () => { |
| 312 | let gw; |
| 313 | before(async () => { gw = await createGateway(); }); |
| 314 | after(() => gw.close()); |
| 315 | |
| 316 | it('response contains no JWT secrets or signing keys', async () => { |
| 317 | const { body } = await post(gw.url, REPAIR, |
| 318 | { uid: ADMIN_SUB, tier: 'plus', stripe_subscription_id: 'sub_secret_check' }, adminToken()); |
| 319 | const s = JSON.stringify(body); |
| 320 | assert.ok(!s.includes(SECRET), 'JWT secret must not appear in response'); |
| 321 | assert.ok(!s.includes('eyJ'), 'JWT token must not appear in response'); |
| 322 | }); |
| 323 | |
| 324 | it('invalid tier returns 400 with a valid_tiers list', async () => { |
| 325 | const { status, body } = await post(gw.url, REPAIR, { tier: 'ultraplus' }, adminToken()); |
| 326 | assert.equal(status, 400); |
| 327 | assert.ok(Array.isArray(body.valid_tiers), 'valid_tiers list must be in 400 response'); |
| 328 | }); |
| 329 | |
| 330 | it('missing tier returns 400', async () => { |
| 331 | const { status } = await post(gw.url, REPAIR, {}, adminToken()); |
| 332 | assert.equal(status, 400); |
| 333 | }); |
| 334 | |
| 335 | it('unknown body fields are silently ignored and do not corrupt the record', async () => { |
| 336 | const { status, body } = await post(gw.url, REPAIR, |
| 337 | { tier: 'plus', evil: 'injection', foo: 123 }, adminToken()); |
| 338 | assert.equal(status, 200); |
| 339 | assert.equal(body.tier, 'plus'); |
| 340 | assert.ok(!('evil' in body), 'unknown fields must not appear in response'); |
| 341 | }); |
| 342 | |
| 343 | it('empty string uid falls back to caller uid (not an empty-string user)', async () => { |
| 344 | const { body } = await post(gw.url, REPAIR, { uid: '', tier: 'plus' }, adminToken()); |
| 345 | assert.equal(body.uid, ADMIN_SUB, 'empty uid must fall back to caller'); |
| 346 | }); |
| 347 | }); |
| 348 | |
| 349 | // ─── 6. Performance ────────────────────────────────────────────────────────── |
| 350 | |
| 351 | describe('admin/billing/repair — performance', () => { |
| 352 | let gw; |
| 353 | before(async () => { gw = await createGateway(); }); |
| 354 | after(() => gw.close()); |
| 355 | |
| 356 | it('responds within 2000 ms', async () => { |
| 357 | const start = Date.now(); |
| 358 | const { status } = await post(gw.url, REPAIR, { tier: 'plus' }, adminToken()); |
| 359 | const elapsed = Date.now() - start; |
| 360 | assert.equal(status, 200); |
| 361 | assert.ok(elapsed < 2000, `must respond within 2000 ms; took ${elapsed} ms`); |
| 362 | }); |
| 363 | }); |
| 364 | |
| 365 | // ─── 7. Security ───────────────────────────────────────────────────────────── |
| 366 | |
| 367 | describe('admin/billing/repair — security', () => { |
| 368 | let gw; |
| 369 | before(async () => { gw = await createGateway(); }); |
| 370 | after(() => gw.close()); |
| 371 | |
| 372 | it('returns 401 with no Authorization header', async () => { |
| 373 | const { status } = await post(gw.url, REPAIR, { tier: 'plus' }, null); |
| 374 | assert.equal(status, 401); |
| 375 | }); |
| 376 | |
| 377 | it('returns 403 for a valid non-admin JWT', async () => { |
| 378 | const { status } = await post(gw.url, REPAIR, { tier: 'plus' }, memberToken()); |
| 379 | assert.equal(status, 403); |
| 380 | }); |
| 381 | |
| 382 | it('returns 401 for a token signed with the wrong secret', async () => { |
| 383 | const badToken = makeJwt(ADMIN_SUB, 'admin', 'wrong-secret'); |
| 384 | const { status } = await post(gw.url, REPAIR, { tier: 'plus' }, badToken); |
| 385 | assert.equal(status, 401); |
| 386 | }); |
| 387 | |
| 388 | it('returns 401 for an alg:none algorithm-confusion token', async () => { |
| 389 | const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); |
| 390 | const payload = Buffer.from(JSON.stringify({ sub: ADMIN_SUB, role: 'admin' })).toString('base64url'); |
| 391 | const { status } = await post(gw.url, REPAIR, { tier: 'plus' }, `${header}.${payload}.`); |
| 392 | assert.equal(status, 401); |
| 393 | }); |
| 394 | |
| 395 | it('member cannot escalate their own tier by targeting their own uid', async () => { |
| 396 | const { status } = await post(gw.url, REPAIR, { uid: MEMBER_SUB, tier: 'pro' }, memberToken()); |
| 397 | assert.equal(status, 403); |
| 398 | }); |
| 399 | |
| 400 | it('response does not include X-Powered-By header', async () => { |
| 401 | const xpb = await new Promise((resolve, reject) => { |
| 402 | const raw = JSON.stringify({ tier: 'plus' }); |
| 403 | const u = new URL(gw.url + REPAIR); |
| 404 | const req = http.request({ |
| 405 | hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST', |
| 406 | headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(raw), |
| 407 | Authorization: `Bearer ${adminToken()}` }, |
| 408 | }, (res) => resolve(res.headers['x-powered-by'])); |
| 409 | req.on('error', reject); |
| 410 | req.write(raw); |
| 411 | req.end(); |
| 412 | }); |
| 413 | assert.ok(!xpb, `X-Powered-By must not be set; got: ${xpb}`); |
| 414 | }); |
| 415 | |
| 416 | it('injection payloads in tier field are rejected with 400', async () => { |
| 417 | const payloads = ["'; DROP TABLE users; --", '{"$gt":""}', '<script>alert(1)</script>', '../../../etc/passwd']; |
| 418 | for (const tier of payloads) { |
| 419 | const { status } = await post(gw.url, REPAIR, { tier }, adminToken()); |
| 420 | assert.equal(status, 400, `injection payload "${tier}" should be rejected`); |
| 421 | } |
| 422 | }); |
| 423 | |
| 424 | it('error responses do not leak stack traces or server internals', async () => { |
| 425 | const { body } = await post(gw.url, REPAIR, { tier: 'bad' }, adminToken()); |
| 426 | const s = JSON.stringify(body); |
| 427 | assert.ok(!s.includes('at '), 'stack traces must not appear in error responses'); |
| 428 | assert.ok(!s.includes('node_modules'), 'internal paths must not be exposed'); |
| 429 | }); |
| 430 | }); |
File History
1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
9 days ago