Add developer API, Prometheus metrics, and community links
This commit is contained in:
127
scripts/test-developer-keys-sql.mjs
Normal file
127
scripts/test-developer-keys-sql.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
|
||||
|
||||
const db = new PGlite();
|
||||
const owner = "00000000-0000-4000-8000-000000000001";
|
||||
const other = "00000000-0000-4000-8000-000000000002";
|
||||
const deleted = "00000000-0000-4000-8000-000000000003";
|
||||
const hash = (i) => i.toString(16).padStart(64, "0");
|
||||
const rpc = async (sql, args = []) => (await db.query(sql, args)).rows[0].value;
|
||||
const create = (i, account = owner, scopes = ["maps:read"], days = 30) =>
|
||||
rpc("select public.developer_key_create($1,$2,$3,$4,$5,$6) value", [
|
||||
account, `Key ${i}`, scopes, hash(i), `mgdev_${i.toString().padStart(8, "0")}`, days,
|
||||
]);
|
||||
const consume = (i) => rpc("select public.developer_key_consume($1) value", [hash(i)]);
|
||||
|
||||
try {
|
||||
await db.exec(`create role anon; create role authenticated; create role service_role bypassrls;
|
||||
create schema auth; create table auth.users(id uuid primary key);
|
||||
create function auth.uid() returns uuid language sql as $$select null::uuid$$;
|
||||
create schema storage; create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
|
||||
for (const file of (await readdir("supabase/migrations")).filter((f) => f.endsWith(".sql")).sort())
|
||||
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
|
||||
await db.exec("grant select,insert,update,delete on all tables in schema public to service_role;");
|
||||
await db.query("insert into auth.users values($1),($2),($3)", [owner, other, deleted]);
|
||||
await db.query("insert into public.profiles(id,role) values($1,'contributor'),($2,'admin'),($3,'contributor')", [owner, other, deleted]);
|
||||
|
||||
// Browser Supabase sessions cannot call RPCs or read hashes/quotas directly.
|
||||
for (const role of ["anon", "authenticated"]) {
|
||||
await db.exec(`set role ${role}`);
|
||||
for (const sql of [
|
||||
"select token_hash from public.developer_api_keys",
|
||||
"select * from public.developer_api_owner_limits",
|
||||
`select public.developer_keys_list('${owner}')`,
|
||||
`select public.developer_key_create('${owner}','forged',array['maps:read'],'${hash(1)}','mgdev_test1234',30)`,
|
||||
`select public.developer_key_revoke('${owner}','${other}')`,
|
||||
`select public.developer_key_consume('${hash(1)}')`,
|
||||
]) await assert.rejects(db.query(sql), /permission denied/);
|
||||
await db.exec("reset role");
|
||||
}
|
||||
await db.exec("set role service_role");
|
||||
assert.deepEqual(await rpc("select public.developer_keys_list($1) value", [owner]), []);
|
||||
for (const scopes of [[], ["admin"], ["maps:read", null], ["account:write"], ["maps:read", "maps:read", "maps:read", "maps:read", "maps:read"]])
|
||||
await assert.rejects(create(1, owner, scopes), /Invalid API key scopes/);
|
||||
for (const days of [0, 366, null]) await assert.rejects(create(1, owner, ["maps:read"], days), /Invalid API key settings/);
|
||||
await assert.rejects(create(1, null), /Account required/);
|
||||
const first = await create(1, owner, ["maps:read", "account:read", "maps:read"]);
|
||||
assert.deepEqual(first.scopes, ["account:read", "maps:read"]);
|
||||
assert.equal(first.name, "Key 1");
|
||||
assert.ok(!JSON.stringify(first).includes(hash(1)));
|
||||
for (let i = 2; i <= 5; i++) await create(i);
|
||||
await assert.rejects(create(6), (error) => error.code === "22023" && error.message === "Maximum 5 active API keys");
|
||||
const otherKey = await create(10, other);
|
||||
assert.equal((await rpc("select public.developer_keys_list($1) value", [owner])).length, 5);
|
||||
assert.equal((await rpc("select public.developer_keys_list($1) value", [other])).length, 1);
|
||||
assert.equal(await rpc("select public.developer_key_revoke($1,$2) value", [other, first.id]), false);
|
||||
assert.equal((await consume(1)).ok, true);
|
||||
assert.equal(await rpc("select public.developer_key_revoke($1,$2) value", [owner, first.id]), true);
|
||||
assert.deepEqual(await consume(1), {ok: false, error: "invalid_key"});
|
||||
await create(6);
|
||||
await db.query("update public.developer_api_keys set expires_at=clock_timestamp()-interval '1 second' where token_hash=$1", [hash(6)]);
|
||||
assert.deepEqual(await consume(6), {ok: false, error: "invalid_key"});
|
||||
await create(7);
|
||||
for (const token of [null, "not-a-hash", hash(999)])
|
||||
assert.deepEqual(await rpc("select public.developer_key_consume($1) value", [token]), {ok:false,error:"invalid_key"});
|
||||
|
||||
// Reset fixtures to the current minute before checking precise quota boundaries.
|
||||
await db.exec("update public.developer_api_keys set rate_window=null,rate_count=0; delete from public.developer_api_owner_limits");
|
||||
for (let n = 1; n <= 60; n++) {
|
||||
const result = await consume(2);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.remaining, 60 - n);
|
||||
}
|
||||
const limited = await consume(2);
|
||||
assert.equal(limited.error, "rate_limited");
|
||||
assert.equal(limited.remaining, 0);
|
||||
assert.ok(limited.resetAt > Date.now() / 1000);
|
||||
for (const i of [3, 4]) for (let n = 0; n < 60; n++) assert.equal((await consume(i)).ok, true);
|
||||
const ownerLimited = await consume(5);
|
||||
assert.equal(ownerLimited.error, "rate_limited");
|
||||
assert.equal(ownerLimited.ownerRemaining, 0);
|
||||
assert.equal((await consume(10)).ok, true, "Another owner has an independent quota");
|
||||
const stored = (await db.query("select rate_count from public.developer_api_keys where token_hash=$1", [hash(5)])).rows[0];
|
||||
assert.equal(stored.rate_count, 0, "Rejected requests do not consume quota");
|
||||
await db.exec("update public.developer_api_keys set rate_window=date_trunc('minute',clock_timestamp())-interval '1 minute'; update public.developer_api_owner_limits set rate_window=date_trunc('minute',clock_timestamp())-interval '1 minute'");
|
||||
const nextWindow = await consume(2);
|
||||
assert.equal(nextWindow.ok, true);
|
||||
assert.equal(nextWindow.remaining, 59);
|
||||
assert.equal(nextWindow.ownerRemaining, 179);
|
||||
assert.ok((await rpc("select public.developer_keys_list($1) value", [owner])).find((k) => k.id === first.id).revokedAt);
|
||||
assert.ok((await rpc("select public.developer_keys_list($1) value", [owner])).find((k) => k.name === "Key 2").lastUsedAt);
|
||||
|
||||
// History stays bounded without losing an older but still active key.
|
||||
await db.query("update public.developer_api_keys set created_at=clock_timestamp()-interval '2 years' where token_hash=$1", [hash(2)]);
|
||||
await db.query(`insert into public.developer_api_keys(owner_id,name,prefix,token_hash,scopes,created_at,expires_at,revoked_at)
|
||||
select $1,'History '||n,'mgdev_history',lpad(to_hex(n),64,'0'),array['maps:read'],
|
||||
clock_timestamp()-make_interval(secs=>n),clock_timestamp()+interval '1 day',clock_timestamp()
|
||||
from generate_series(1000,1119) n`, [owner]);
|
||||
const bounded = await rpc("select public.developer_keys_list($1) value", [owner]);
|
||||
assert.equal(bounded.length, 100);
|
||||
assert.deepEqual(new Set(bounded.slice(0,5).map((k) => k.name)), new Set(["Key 2","Key 3","Key 4","Key 5","Key 7"]));
|
||||
assert.equal(bounded[4].name, "Key 2");
|
||||
assert.ok(bounded.slice(5).every((k) => k.revokedAt || Date.parse(k.expiresAt) <= Date.now()));
|
||||
assert.ok(bounded.every((k) => !Object.hasOwn(k,"token_hash") && !Object.hasOwn(k,"tokenHash") && !Object.hasOwn(k,"rate_count")));
|
||||
|
||||
// PGlite queues these invocations on one connection. This checks burst
|
||||
// boundaries; true multi-connection locking is provided by FOR UPDATE in SQL.
|
||||
const creationBurst = await Promise.allSettled(Array.from({length:6},(_,n) => create(20+n,deleted)));
|
||||
assert.equal(creationBurst.filter((result) => result.status === "fulfilled").length, 5);
|
||||
assert.equal(creationBurst.filter((result) => result.status === "rejected" && result.reason.code === "22023").length, 1);
|
||||
const quotaBurst = await Promise.all(Array.from({length:70},() => consume(20)));
|
||||
assert.equal(quotaBurst.filter((result) => result.ok).length, 60);
|
||||
assert.equal(quotaBurst.filter((result) => result.error === "rate_limited").length, 10);
|
||||
await db.exec("reset role");
|
||||
await db.query("delete from auth.users where id=$1", [deleted]);
|
||||
await db.exec("set role service_role");
|
||||
assert.deepEqual(await consume(20), {ok:false,error:"invalid_key"});
|
||||
await assert.rejects(create(21, deleted), /Account required/);
|
||||
await db.exec("reset role");
|
||||
await db.query("delete from public.profiles where id=$1", [other]);
|
||||
assert.equal((await db.query("select count(*) n from public.developer_api_keys where id=$1", [otherKey.id])).rows[0].n, 0);
|
||||
await db.exec("set role service_role");
|
||||
assert.deepEqual(await consume(10), {ok:false,error:"invalid_key"});
|
||||
console.log("Developer API SQL checks passed: restricted grants, validation, scope canonicalization, owner isolation, five-key cap, expiry, revocation, deleted accounts, per-key 60/min, shared-owner 180/min, rollover, bounded active-first metadata, queued burst boundaries.");
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
196
scripts/test-prometheus-sql.mjs
Normal file
196
scripts/test-prometheus-sql.mjs
Normal file
@@ -0,0 +1,196 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
|
||||
const db = new PGlite();
|
||||
const owner = "00000000-0000-4000-8000-000000000001",
|
||||
moderator = "00000000-0000-4000-8000-000000000002",
|
||||
player = "00000000-0000-4000-8000-000000000003";
|
||||
const oldHash = "a".repeat(64),
|
||||
newHash = "b".repeat(64);
|
||||
try {
|
||||
await db.exec(`create role anon; create role authenticated; create role service_role bypassrls;
|
||||
create schema auth; create table auth.users(id uuid primary key);
|
||||
create function auth.uid() returns uuid language sql as $$ select null::uuid $$;
|
||||
create schema storage; create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
|
||||
for (const file of (await readdir("supabase/migrations"))
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort())
|
||||
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
|
||||
// Supabase's default service_role table grants are not installed in PGlite.
|
||||
await db.exec(
|
||||
"grant select,insert,update,delete on all tables in schema public to service_role;",
|
||||
);
|
||||
await db.query("insert into auth.users values($1),($2),($3)", [
|
||||
owner,
|
||||
moderator,
|
||||
player,
|
||||
]);
|
||||
await db.query(
|
||||
"insert into public.profiles(id,role) values($1,'admin'),($2,'moderator'),($3,'contributor')",
|
||||
[owner, moderator, player],
|
||||
);
|
||||
const dataset = (
|
||||
await db.query(
|
||||
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
|
||||
values($1,'Private map title','plugin','{}','published',7) returning id`,
|
||||
[owner],
|
||||
)
|
||||
).rows[0].id;
|
||||
const revision = (
|
||||
await db.query(
|
||||
`insert into public.revisions(dataset_id,manifest,asset_root)
|
||||
values($1,'{}','private-root') returning id`,
|
||||
[dataset],
|
||||
)
|
||||
).rows[0].id;
|
||||
await db.query(
|
||||
`insert into public.game_sessions(revision_id,access_hash,difficulty,current_round,created_at)
|
||||
values($1,'test','easy',5,now()),($1,'test','hard',0,now()-interval '2 days')`,
|
||||
[revision],
|
||||
);
|
||||
const rpc = async (sql, args = []) =>
|
||||
(await db.query(sql, args)).rows[0].value;
|
||||
for (const role of ["anon", "authenticated"]) {
|
||||
await db.exec(`set role ${role}`);
|
||||
for (const sql of [
|
||||
"select token_hash from public.prometheus_settings",
|
||||
`select public.prometheus_status('${owner}')`,
|
||||
`select public.configure_prometheus('${owner}','${oldHash}')`,
|
||||
`select public.read_prometheus_metrics('${oldHash}')`,
|
||||
])
|
||||
await assert.rejects(db.query(sql), /permission denied/);
|
||||
await db.exec("reset role");
|
||||
}
|
||||
await db.exec("set role service_role");
|
||||
for (const actor of [moderator, player, null]) {
|
||||
await assert.rejects(
|
||||
db.query("select public.prometheus_status($1)", [actor]),
|
||||
/Admin permission required/,
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query("select public.configure_prometheus($1,$2)", [actor, oldHash]),
|
||||
/Admin permission required/,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(await rpc("select public.prometheus_status($1) value", [owner])).enabled,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [oldHash]),
|
||||
null,
|
||||
);
|
||||
await rpc("select public.configure_prometheus($1,$2) value", [
|
||||
owner,
|
||||
oldHash,
|
||||
]);
|
||||
const snapshot = await rpc(
|
||||
"select public.read_prometheus_metrics($1) value",
|
||||
[oldHash],
|
||||
);
|
||||
assert.equal(snapshot.length, 43);
|
||||
assert.equal(snapshot.find((s) => s.metric === "minegeo_accounts").value, 3);
|
||||
assert.equal(
|
||||
snapshot.find(
|
||||
(s) => s.metric === "minegeo_maps" && s.labels.state === "published",
|
||||
).value,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.find((s) => s.metric === "minegeo_published_panoramas").value,
|
||||
7,
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.find((s) => s.metric === "minegeo_games_started_24h").value,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.find(
|
||||
(s) =>
|
||||
s.metric === "minegeo_games" &&
|
||||
s.labels.difficulty === "easy" &&
|
||||
s.labels.status === "complete",
|
||||
).value,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.find(
|
||||
(s) =>
|
||||
s.metric === "minegeo_games" &&
|
||||
s.labels.difficulty === "hard" &&
|
||||
s.labels.status === "incomplete",
|
||||
).value,
|
||||
1,
|
||||
);
|
||||
assert.ok(!JSON.stringify(snapshot).includes("Private map title"));
|
||||
assert.ok(
|
||||
snapshot.every(
|
||||
(s) => Object.keys(s).sort().join(",") === "labels,metric,value",
|
||||
),
|
||||
);
|
||||
assert.ok(!JSON.stringify(snapshot).includes(owner));
|
||||
assert.ok(
|
||||
(await rpc("select public.prometheus_status($1) value", [owner]))
|
||||
.lastScrapedAt,
|
||||
);
|
||||
// Cache is shared by scrapes rather than tied to a serverless process.
|
||||
await db.query(
|
||||
"update public.profiles set display_name='Private name' where id=$1",
|
||||
[player],
|
||||
);
|
||||
assert.deepEqual(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [oldHash]),
|
||||
snapshot,
|
||||
);
|
||||
await db.query("update public.datasets set state='unpublished' where id=$1", [
|
||||
dataset,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [oldHash]),
|
||||
snapshot,
|
||||
);
|
||||
await db.exec(
|
||||
"update public.prometheus_settings set snapshot_at=now()-interval '1 minute'",
|
||||
);
|
||||
const refreshed = await rpc(
|
||||
"select public.read_prometheus_metrics($1) value",
|
||||
[oldHash],
|
||||
);
|
||||
assert.equal(
|
||||
refreshed.find(
|
||||
(s) => s.metric === "minegeo_maps" && s.labels.state === "published",
|
||||
).value,
|
||||
0,
|
||||
);
|
||||
await rpc("select public.configure_prometheus($1,$2) value", [
|
||||
owner,
|
||||
newHash,
|
||||
]);
|
||||
assert.equal(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [oldHash]),
|
||||
null,
|
||||
);
|
||||
assert.ok(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [newHash]),
|
||||
);
|
||||
await db.query("update public.profiles set role='contributor' where id=$1", [
|
||||
owner,
|
||||
]);
|
||||
assert.equal(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [newHash]),
|
||||
null,
|
||||
);
|
||||
await db.query("update public.profiles set role='admin' where id=$1", [
|
||||
owner,
|
||||
]);
|
||||
await rpc("select public.configure_prometheus($1,null) value", [owner]);
|
||||
assert.equal(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [newHash]),
|
||||
null,
|
||||
);
|
||||
console.log(
|
||||
"Prometheus SQL passed: Admin-only setup, private table/RPCs, disabled default, bounded aggregates, cache, rotation, disable, and issuer demotion.",
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
Reference in New Issue
Block a user