@gigzen/populace 0.1.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/LICENSE +661 -0
- package/README.md +258 -0
- package/adapters/buzzbuzz.mjs +247 -0
- package/adapters/contract.md +164 -0
- package/adapters/template-rest.mjs +192 -0
- package/adapters/template.mjs +80 -0
- package/examples/buzzbuzz/populace-report.html +245 -0
- package/examples/buzzbuzz/populace-report.json +280 -0
- package/examples/buzzbuzz/populace.config.mjs +51 -0
- package/examples/buzzbuzz/run-test.ps1 +61 -0
- package/examples/demo/adapters/demo.mjs +90 -0
- package/examples/demo/populace-report.html +230 -0
- package/examples/demo/populace-report.json +219 -0
- package/examples/demo/populace.config.mjs +22 -0
- package/examples/rest-api/README.md +85 -0
- package/examples/rest-api/adapter.mjs +166 -0
- package/examples/rest-api/populace.config.mjs +40 -0
- package/examples/rest-api/server.mjs +247 -0
- package/examples/token-expiry/expiry-demo.mjs +119 -0
- package/package.json +56 -0
- package/populace.config.example.mjs +65 -0
- package/src/cli.mjs +591 -0
- package/src/config.mjs +186 -0
- package/src/contract.mjs +130 -0
- package/src/diagnose.mjs +40 -0
- package/src/engine/agent.mjs +264 -0
- package/src/engine/geo.mjs +59 -0
- package/src/engine/index.mjs +4 -0
- package/src/engine/personas.mjs +115 -0
- package/src/engine/world.mjs +120 -0
- package/src/html-report.mjs +218 -0
- package/src/index.mjs +38 -0
- package/src/instrument.mjs +299 -0
- package/src/net.mjs +175 -0
- package/src/report.mjs +251 -0
- package/src/selftest.mjs +1369 -0
- package/src/smoke.mjs +274 -0
- package/src/version.mjs +24 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An adapter for a plain REST API — the second one Populace has ever had, and
|
|
3
|
+
* the reason the portability claim is more than a hope.
|
|
4
|
+
*
|
|
5
|
+
* Read this next to adapters/buzzbuzz.mjs. They share no library, no id type,
|
|
6
|
+
* no error convention and no auth mechanism, and the engine cannot tell them
|
|
7
|
+
* apart. That is the whole argument.
|
|
8
|
+
*
|
|
9
|
+
* If your API looks like this one — HTTP, JSON, a bearer token — you can copy
|
|
10
|
+
* this file and change the URLs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export function createAdapter(target) {
|
|
14
|
+
const base = target.url.replace(/\/$/, "");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The contract hands an adapter a name, a phone and a persona — never a
|
|
18
|
+
* password. Choosing one is the adapter's job, exactly as the Buzz Buzz
|
|
19
|
+
* reference adapter does, because only you know what your API will accept.
|
|
20
|
+
*
|
|
21
|
+
* A constant is right here: identities are deterministic so that a re-run
|
|
22
|
+
* signs back in rather than piling up new accounts, and that only works if
|
|
23
|
+
* the credential is stable too.
|
|
24
|
+
*/
|
|
25
|
+
const PASSWORD = "PopulaceDemo!2026";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One place where HTTP becomes either a value or a thrown Error.
|
|
29
|
+
*
|
|
30
|
+
* Populace groups failures by shape, so the message must describe the fault
|
|
31
|
+
* and not the request: "409 on /likes" tells you which endpoint is unhappy,
|
|
32
|
+
* whereas including the post id would make every occurrence unique and turn
|
|
33
|
+
* one bug into fifty lines in the report.
|
|
34
|
+
*/
|
|
35
|
+
async function call(method, path, { token, body } = {}) {
|
|
36
|
+
const res = await fetch(`${base}${path}`, {
|
|
37
|
+
method,
|
|
38
|
+
headers: {
|
|
39
|
+
...(body ? { "content-type": "application/json" } : {}),
|
|
40
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
41
|
+
},
|
|
42
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
43
|
+
signal: AbortSignal.timeout(15000),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (res.status === 204) return undefined;
|
|
47
|
+
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
let payload;
|
|
50
|
+
try { payload = text ? JSON.parse(text) : undefined; } catch { payload = undefined; }
|
|
51
|
+
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
const detail = payload?.error || text.slice(0, 120) || "no body";
|
|
54
|
+
// Never swallow this. An adapter that returns on failure makes the report
|
|
55
|
+
// blame a later method for an earlier fault — the exact defect Populace's
|
|
56
|
+
// own first run found in its own reference adapter.
|
|
57
|
+
throw new Error(`${res.status} on ${method} ${path}: ${detail}`);
|
|
58
|
+
}
|
|
59
|
+
return payload;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name: "rest-api-demo",
|
|
64
|
+
|
|
65
|
+
async healthCheck() {
|
|
66
|
+
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(10000) });
|
|
67
|
+
if (!res.ok) throw new Error(`health check returned ${res.status}`);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
// ── identity ────────────────────────────────────────────────────────
|
|
71
|
+
// Signature per adapters/contract.md: { name, phone, persona, index }.
|
|
72
|
+
async createUser({ name, phone }) {
|
|
73
|
+
const out = await call("POST", "/auth/signup", {
|
|
74
|
+
body: { phone, password: PASSWORD, name },
|
|
75
|
+
});
|
|
76
|
+
// The handle is whatever later calls need. Here that is an integer id and
|
|
77
|
+
// a token; for Buzz Buzz it was a UUID and a client object. The engine
|
|
78
|
+
// only ever reads `.id`.
|
|
79
|
+
return { id: out.id, token: out.token };
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
// Lets `populace clean` check for leftovers without creating accounts to
|
|
83
|
+
// find out whether they exist.
|
|
84
|
+
async signIn({ phone }) {
|
|
85
|
+
try {
|
|
86
|
+
const out = await call("POST", "/auth/signin", {
|
|
87
|
+
body: { phone, password: PASSWORD },
|
|
88
|
+
});
|
|
89
|
+
return { id: out.id, token: out.token };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (/^401/.test(error.message)) return null; // genuinely not there
|
|
92
|
+
throw error; // anything else is real
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// The server issues 15-minute tokens. Without this a run longer than that
|
|
97
|
+
// collapses at once, the report blames the API, and cleanup cannot even
|
|
98
|
+
// delete its own accounts.
|
|
99
|
+
async refreshSession(user) {
|
|
100
|
+
const out = await call("POST", "/auth/refresh", { token: user.token });
|
|
101
|
+
user.token = out.token;
|
|
102
|
+
return user;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async setProfile(user, persona) {
|
|
106
|
+
await call("PATCH", "/me", {
|
|
107
|
+
token: user.token,
|
|
108
|
+
body: { name: persona.name, city: persona.city?.name },
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async deleteUser(user) {
|
|
113
|
+
await call("DELETE", "/me", { token: user.token });
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
// ── movement ────────────────────────────────────────────────────────
|
|
117
|
+
async reportLocation(user, { lat, lng, distanceKm }) {
|
|
118
|
+
await call("POST", "/locations", {
|
|
119
|
+
token: user.token,
|
|
120
|
+
body: { lat, lng, km: distanceKm },
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// ── social ──────────────────────────────────────────────────────────
|
|
125
|
+
async post(user, body) {
|
|
126
|
+
const out = await call("POST", "/posts", { token: user.token, body: { body } });
|
|
127
|
+
return out.id; // the engine needs this for like/comment
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// Returns bare integer ids, not row objects. Perfectly legal under the
|
|
131
|
+
// contract, and the shape that used to break the engine.
|
|
132
|
+
async recentPostsByOthers(user) {
|
|
133
|
+
return await call("GET", "/posts/others", { token: user.token });
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async like(user, postId) {
|
|
137
|
+
await call("POST", "/likes", { token: user.token, body: { postId } });
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
async comment(user, postId, body) {
|
|
141
|
+
await call("POST", "/comments", { token: user.token, body: { postId, body } });
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
// ── messaging ───────────────────────────────────────────────────────
|
|
145
|
+
async openConversation(user, otherUserId) {
|
|
146
|
+
const out = await call("POST", "/threads", {
|
|
147
|
+
token: user.token,
|
|
148
|
+
body: { otherUserId },
|
|
149
|
+
});
|
|
150
|
+
return out.id;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async sendMessage(user, threadId, body) {
|
|
154
|
+
await call("POST", "/messages", { token: user.token, body: { threadId, body } });
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
// ── groups ──────────────────────────────────────────────────────────
|
|
158
|
+
async listGroups(user) {
|
|
159
|
+
return await call("GET", "/groups", { token: user.token });
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async joinGroup(user, groupId) {
|
|
163
|
+
await call("POST", "/groups/join", { token: user.token, body: { groupId } });
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Populace against a plain REST API.
|
|
3
|
+
*
|
|
4
|
+
* Two terminals:
|
|
5
|
+
*
|
|
6
|
+
* node examples/rest-api/server.mjs
|
|
7
|
+
* node src/cli.mjs run --config examples/rest-api/populace.config.mjs
|
|
8
|
+
*
|
|
9
|
+
* Nothing to sign up for and nothing to configure — the server is in this
|
|
10
|
+
* repository, holds everything in memory, and forgets it all when you stop it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
app: "REST API demo",
|
|
15
|
+
adapter: "./adapter.mjs",
|
|
16
|
+
environment: "test",
|
|
17
|
+
|
|
18
|
+
target: {
|
|
19
|
+
url: process.env.REST_DEMO_URL || "http://127.0.0.1:8787",
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
// The demo server binds to loopback and has no real authentication, so it
|
|
23
|
+
// cannot be a production host. The guard is here anyway: an empty denylist
|
|
24
|
+
// warns loudly, and "the config I copied had nothing in it" is the likeliest
|
|
25
|
+
// way someone ends up pointing this at something real.
|
|
26
|
+
neverRunAgainst: [
|
|
27
|
+
"https://api.example.com",
|
|
28
|
+
"https://production",
|
|
29
|
+
],
|
|
30
|
+
|
|
31
|
+
population: {
|
|
32
|
+
agents: 6,
|
|
33
|
+
cities: ["manila", "mumbai"],
|
|
34
|
+
minutes: 2,
|
|
35
|
+
tickSeconds: 3,
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
identity: { phonePrefix: "0700" },
|
|
39
|
+
report: { path: "populace-report.json" },
|
|
40
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small REST API, deliberately unlike the Supabase one Populace grew up on.
|
|
3
|
+
*
|
|
4
|
+
* The point of this file is not the server. It is the claim it lets us test:
|
|
5
|
+
* that the engine is app-agnostic. Populace had only ever been pointed at one
|
|
6
|
+
* real backend, and that backend was ours — a testing tool that has only tested
|
|
7
|
+
* its author's own app has not been shown to be portable.
|
|
8
|
+
*
|
|
9
|
+
* So every architectural choice here disagrees with Buzz Buzz on purpose:
|
|
10
|
+
*
|
|
11
|
+
* Buzz Buzz / Supabase this server
|
|
12
|
+
* ──────────────────── ───────────────────────────────
|
|
13
|
+
* supabase-js client plain fetch over HTTP
|
|
14
|
+
* UUID string ids INTEGER ids
|
|
15
|
+
* {data, error} tuples HTTP status codes + {error: "..."}
|
|
16
|
+
* session object on the client Bearer token in an Authorization header
|
|
17
|
+
* list endpoints return rows list endpoints return BARE ID ARRAYS
|
|
18
|
+
* RLS refuses in the database the handler refuses in application code
|
|
19
|
+
*
|
|
20
|
+
* The bare id arrays matter most: that shape is what caught the engine reading
|
|
21
|
+
* `target.id` off a string. If this file agreed with Buzz Buzz it would prove
|
|
22
|
+
* nothing.
|
|
23
|
+
*
|
|
24
|
+
* Zero dependencies, in-memory, and it binds to 127.0.0.1 only — it is a test
|
|
25
|
+
* fixture, not something to deploy.
|
|
26
|
+
*
|
|
27
|
+
* node examples/rest-api/server.mjs # starts on :8787
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import http from "node:http";
|
|
31
|
+
import crypto from "node:crypto";
|
|
32
|
+
|
|
33
|
+
const PORT = Number(process.env.PORT || 8787);
|
|
34
|
+
|
|
35
|
+
/* ── storage ───────────────────────────────────────────────────────────── */
|
|
36
|
+
const db = {
|
|
37
|
+
users: new Map(), // id -> { id, phone, password, name, city, deleted }
|
|
38
|
+
tokens: new Map(), // token -> { userId, expiresAt }
|
|
39
|
+
posts: [], // { id, authorId, body }
|
|
40
|
+
likes: new Set(), // `${postId}:${userId}`
|
|
41
|
+
comments: [], // { id, postId, authorId, body }
|
|
42
|
+
threads: [], // { id, a, b }
|
|
43
|
+
messages: [], // { id, threadId, senderId, body }
|
|
44
|
+
groups: [{ id: 1, name: "Night shift" }, { id: 2, name: "Airport runs" }],
|
|
45
|
+
members: new Set(), // `${groupId}:${userId}`
|
|
46
|
+
locations: [], // { userId, lat, lng, km }
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let nextId = 1;
|
|
50
|
+
const newId = () => nextId++; // INTEGER ids, not UUIDs
|
|
51
|
+
|
|
52
|
+
/* Short-lived on purpose: a run longer than this must call the refresh
|
|
53
|
+
endpoint, which is how adapters that forget refreshSession get caught. */
|
|
54
|
+
const TOKEN_TTL_MS = 15 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
function issueToken(userId) {
|
|
57
|
+
const token = crypto.randomBytes(24).toString("hex");
|
|
58
|
+
db.tokens.set(token, { userId, expiresAt: Date.now() + TOKEN_TTL_MS });
|
|
59
|
+
return token;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function authenticate(req) {
|
|
63
|
+
const header = req.headers.authorization || "";
|
|
64
|
+
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
|
|
65
|
+
if (!token) return { error: 401, message: "missing bearer token" };
|
|
66
|
+
const entry = db.tokens.get(token);
|
|
67
|
+
if (!entry) return { error: 401, message: "unknown token" };
|
|
68
|
+
if (entry.expiresAt < Date.now()) return { error: 401, message: "token expired" };
|
|
69
|
+
const user = db.users.get(entry.userId);
|
|
70
|
+
if (!user || user.deleted) return { error: 401, message: "account no longer exists" };
|
|
71
|
+
return { user, token };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ── helpers ───────────────────────────────────────────────────────────── */
|
|
75
|
+
const send = (res, status, body) => {
|
|
76
|
+
const payload = body === undefined ? "" : JSON.stringify(body);
|
|
77
|
+
res.writeHead(status, {
|
|
78
|
+
"content-type": "application/json",
|
|
79
|
+
"content-length": Buffer.byteLength(payload),
|
|
80
|
+
});
|
|
81
|
+
res.end(payload);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const readBody = (req) =>
|
|
85
|
+
new Promise((resolve, reject) => {
|
|
86
|
+
let raw = "";
|
|
87
|
+
req.on("data", (c) => {
|
|
88
|
+
raw += c;
|
|
89
|
+
if (raw.length > 1e6) reject(new Error("body too large"));
|
|
90
|
+
});
|
|
91
|
+
req.on("end", () => {
|
|
92
|
+
if (!raw) return resolve({});
|
|
93
|
+
try { resolve(JSON.parse(raw)); } catch { reject(new Error("invalid JSON")); }
|
|
94
|
+
});
|
|
95
|
+
req.on("error", reject);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/* ── routes ────────────────────────────────────────────────────────────── */
|
|
99
|
+
const routes = {
|
|
100
|
+
"GET /health": async () => [200, { ok: true }],
|
|
101
|
+
|
|
102
|
+
"POST /auth/signup": async (_req, body) => {
|
|
103
|
+
if (!body.phone || !body.password) return [400, { error: "phone and password required" }];
|
|
104
|
+
// Deterministic identities mean re-runs re-use accounts rather than piling
|
|
105
|
+
// up new ones, so signup on an existing phone is a sign-in.
|
|
106
|
+
const existing = [...db.users.values()].find((u) => u.phone === body.phone && !u.deleted);
|
|
107
|
+
if (existing) {
|
|
108
|
+
if (existing.password !== body.password) return [401, { error: "wrong password" }];
|
|
109
|
+
return [200, { id: existing.id, token: issueToken(existing.id) }];
|
|
110
|
+
}
|
|
111
|
+
const id = newId();
|
|
112
|
+
db.users.set(id, { id, phone: body.phone, password: body.password, name: body.name || "", deleted: false });
|
|
113
|
+
return [201, { id, token: issueToken(id) }];
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
"POST /auth/signin": async (_req, body) => {
|
|
117
|
+
const user = [...db.users.values()].find((u) => u.phone === body.phone && !u.deleted);
|
|
118
|
+
if (!user || user.password !== body.password) return [401, { error: "invalid credentials" }];
|
|
119
|
+
return [200, { id: user.id, token: issueToken(user.id) }];
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
"POST /auth/refresh": async (req) => {
|
|
123
|
+
const header = req.headers.authorization || "";
|
|
124
|
+
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
|
|
125
|
+
const entry = token && db.tokens.get(token);
|
|
126
|
+
// An EXPIRED token can still be refreshed; an unknown one cannot.
|
|
127
|
+
if (!entry) return [401, { error: "unknown token" }];
|
|
128
|
+
db.tokens.delete(token);
|
|
129
|
+
return [200, { token: issueToken(entry.userId) }];
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
"PATCH /me": async (req, body, auth) => {
|
|
133
|
+
auth.user.name = body.name ?? auth.user.name;
|
|
134
|
+
auth.user.city = body.city ?? auth.user.city;
|
|
135
|
+
return [200, { id: auth.user.id }];
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
"DELETE /me": async (_req, _body, auth) => {
|
|
139
|
+
auth.user.deleted = true;
|
|
140
|
+
for (const [t, e] of db.tokens) if (e.userId === auth.user.id) db.tokens.delete(t);
|
|
141
|
+
return [204, undefined];
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
"POST /locations": async (_req, body, auth) => {
|
|
145
|
+
if (typeof body.lat !== "number" || typeof body.lng !== "number") {
|
|
146
|
+
return [400, { error: "lat and lng must be numbers" }];
|
|
147
|
+
}
|
|
148
|
+
db.locations.push({ userId: auth.user.id, lat: body.lat, lng: body.lng, km: body.km ?? 0 });
|
|
149
|
+
return [201, { ok: true }];
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
"POST /posts": async (_req, body, auth) => {
|
|
153
|
+
if (!body.body) return [400, { error: "body required" }];
|
|
154
|
+
const id = newId();
|
|
155
|
+
db.posts.push({ id, authorId: auth.user.id, body: body.body });
|
|
156
|
+
return [201, { id }];
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
// Bare ids, not objects. This is the shape that caught the engine.
|
|
160
|
+
"GET /posts/others": async (_req, _body, auth) =>
|
|
161
|
+
[200, db.posts.filter((p) => p.authorId !== auth.user.id).slice(-10).map((p) => p.id)],
|
|
162
|
+
|
|
163
|
+
"POST /likes": async (_req, body, auth) => {
|
|
164
|
+
const post = db.posts.find((p) => p.id === body.postId);
|
|
165
|
+
if (!post) return [404, { error: "no such post" }];
|
|
166
|
+
const key = `${body.postId}:${auth.user.id}`;
|
|
167
|
+
// Liking twice is a no-op, not an error. The Supabase version got this
|
|
168
|
+
// wrong via upsert, and any port of that mistake would show up here.
|
|
169
|
+
db.likes.add(key);
|
|
170
|
+
return [201, { ok: true }];
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
"POST /comments": async (_req, body, auth) => {
|
|
174
|
+
const post = db.posts.find((p) => p.id === body.postId);
|
|
175
|
+
if (!post) return [404, { error: "no such post" }];
|
|
176
|
+
const id = newId();
|
|
177
|
+
db.comments.push({ id, postId: body.postId, authorId: auth.user.id, body: body.body });
|
|
178
|
+
return [201, { id }];
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
"POST /threads": async (_req, body, auth) => {
|
|
182
|
+
const other = Number(body.otherUserId);
|
|
183
|
+
if (!Number.isInteger(other)) return [400, { error: "otherUserId must be an integer" }];
|
|
184
|
+
if (other === auth.user.id) return [400, { error: "cannot open a thread with yourself" }];
|
|
185
|
+
if (!db.users.has(other)) return [404, { error: "no such user" }];
|
|
186
|
+
const found = db.threads.find(
|
|
187
|
+
(t) => (t.a === auth.user.id && t.b === other) || (t.a === other && t.b === auth.user.id),
|
|
188
|
+
);
|
|
189
|
+
if (found) return [200, { id: found.id }];
|
|
190
|
+
const id = newId();
|
|
191
|
+
db.threads.push({ id, a: auth.user.id, b: other });
|
|
192
|
+
return [201, { id }];
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
"POST /messages": async (_req, body, auth) => {
|
|
196
|
+
const thread = db.threads.find((t) => t.id === Number(body.threadId));
|
|
197
|
+
if (!thread) return [404, { error: "no such thread" }];
|
|
198
|
+
// Refused in application code rather than by a database policy — a
|
|
199
|
+
// different enforcement point from Buzz Buzz, deliberately.
|
|
200
|
+
if (thread.a !== auth.user.id && thread.b !== auth.user.id) {
|
|
201
|
+
return [403, { error: "not a participant in this thread" }];
|
|
202
|
+
}
|
|
203
|
+
const id = newId();
|
|
204
|
+
db.messages.push({ id, threadId: thread.id, senderId: auth.user.id, body: body.body });
|
|
205
|
+
return [201, { id }];
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
"GET /groups": async () => [200, db.groups.map((g) => g.id)], // bare ids again
|
|
209
|
+
|
|
210
|
+
"POST /groups/join": async (_req, body, auth) => {
|
|
211
|
+
const group = db.groups.find((g) => g.id === Number(body.groupId));
|
|
212
|
+
if (!group) return [404, { error: "no such group" }];
|
|
213
|
+
db.members.add(`${group.id}:${auth.user.id}`); // idempotent, like /likes
|
|
214
|
+
return [201, { ok: true }];
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const PUBLIC = new Set(["GET /health", "POST /auth/signup", "POST /auth/signin", "POST /auth/refresh"]);
|
|
219
|
+
|
|
220
|
+
const server = http.createServer(async (req, res) => {
|
|
221
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
222
|
+
const key = `${req.method} ${url.pathname}`;
|
|
223
|
+
const handler = routes[key];
|
|
224
|
+
if (!handler) return send(res, 404, { error: `no route for ${key}` });
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const body = ["POST", "PATCH", "PUT"].includes(req.method) ? await readBody(req) : {};
|
|
228
|
+
let auth = null;
|
|
229
|
+
if (!PUBLIC.has(key)) {
|
|
230
|
+
auth = authenticate(req);
|
|
231
|
+
if (auth.error) return send(res, auth.error, { error: auth.message });
|
|
232
|
+
}
|
|
233
|
+
const [status, payload] = await handler(req, body, auth);
|
|
234
|
+
send(res, status, payload);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
send(res, 400, { error: error.message });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// 127.0.0.1, never 0.0.0.0: this has no real authentication and must not be
|
|
241
|
+
// reachable from another machine.
|
|
242
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
243
|
+
console.log(` demo REST API on http://127.0.0.1:${PORT}`);
|
|
244
|
+
console.log(` integer ids · bearer tokens · bare id arrays · ${TOKEN_TTL_MS / 60000}min token TTL`);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
export { server, db };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// A visible before/after for the session-expiry defect.
|
|
2
|
+
//
|
|
3
|
+
// node examples/token-expiry/expiry-demo.mjs
|
|
4
|
+
//
|
|
5
|
+
// Both runs use the SAME population against the SAME pretend app — an app whose
|
|
6
|
+
// access tokens expire after 3 seconds. The only difference is whether the
|
|
7
|
+
// adapter implements refreshSession().
|
|
8
|
+
//
|
|
9
|
+
// This is the failure worth demonstrating because of how it LIES: the agents
|
|
10
|
+
// keep moving, the table keeps updating, the run looks healthy — while every
|
|
11
|
+
// single call is rejected. A customer would read that as their own API falling
|
|
12
|
+
// over under load.
|
|
13
|
+
|
|
14
|
+
import { World } from "../../src/engine/world.mjs";
|
|
15
|
+
import { buildPersonas } from "../../src/engine/personas.mjs";
|
|
16
|
+
import { createMetrics, instrument, summarise } from "../../src/instrument.mjs";
|
|
17
|
+
|
|
18
|
+
const TOKEN_TTL_MS = 3000;
|
|
19
|
+
|
|
20
|
+
function pretendApp({ supportsRefresh }) {
|
|
21
|
+
const sessions = new Map();
|
|
22
|
+
const seen = { posts: 0, locations: 0 };
|
|
23
|
+
|
|
24
|
+
const assertLive = (user) => {
|
|
25
|
+
const expiresAt = sessions.get(user.id);
|
|
26
|
+
if (!expiresAt || Date.now() > expiresAt) {
|
|
27
|
+
throw new Error(`JWT expired (token issued for user ${user.id})`);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const adapter = {
|
|
32
|
+
name: supportsRefresh ? "with-refresh" : "no-refresh",
|
|
33
|
+
async createUser({ name, phone }) {
|
|
34
|
+
const id = `u_${phone}`;
|
|
35
|
+
sessions.set(id, Date.now() + TOKEN_TTL_MS);
|
|
36
|
+
return { id, name };
|
|
37
|
+
},
|
|
38
|
+
async setProfile(user) {
|
|
39
|
+
assertLive(user);
|
|
40
|
+
},
|
|
41
|
+
async reportLocation(user) {
|
|
42
|
+
assertLive(user);
|
|
43
|
+
seen.locations += 1;
|
|
44
|
+
},
|
|
45
|
+
async post(user) {
|
|
46
|
+
assertLive(user);
|
|
47
|
+
seen.posts += 1;
|
|
48
|
+
return `p${seen.posts}`;
|
|
49
|
+
},
|
|
50
|
+
async recentPostsByOthers(user) {
|
|
51
|
+
assertLive(user);
|
|
52
|
+
return [];
|
|
53
|
+
},
|
|
54
|
+
async deleteUser(user) {
|
|
55
|
+
assertLive(user);
|
|
56
|
+
sessions.delete(user.id);
|
|
57
|
+
},
|
|
58
|
+
seen,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (supportsRefresh) {
|
|
62
|
+
adapter.refreshSession = async (user) => {
|
|
63
|
+
if (!sessions.has(user.id)) throw new Error("no session to refresh");
|
|
64
|
+
sessions.set(user.id, Date.now() + TOKEN_TTL_MS);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return adapter;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function go({ supportsRefresh }) {
|
|
71
|
+
const app = pretendApp({ supportsRefresh });
|
|
72
|
+
const metrics = createMetrics();
|
|
73
|
+
const world = new World({
|
|
74
|
+
adapter: instrument(app, metrics),
|
|
75
|
+
personas: buildPersonas(4, ["manila", "mumbai"]),
|
|
76
|
+
// Refresh well inside the 3s token, the same way the real default sits
|
|
77
|
+
// well inside a 1-hour one.
|
|
78
|
+
options: { refreshEveryMs: 1200 },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await world.populate({ staggerMs: 0 });
|
|
82
|
+
await world.run({ minutes: 0.25, tickSeconds: 1, realtime: true });
|
|
83
|
+
metrics.endedAt = Date.now();
|
|
84
|
+
|
|
85
|
+
const api = summarise(metrics);
|
|
86
|
+
const teardown = await world.teardown();
|
|
87
|
+
return { api, world, app, teardown };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const pct = (n) => `${(n * 100).toFixed(0)}%`;
|
|
91
|
+
const row = (label, a, b) => console.log(` ${label.padEnd(30)}${String(a).padStart(18)}${String(b).padStart(18)}`);
|
|
92
|
+
|
|
93
|
+
console.log(`\n Pretend app: access tokens expire after ${TOKEN_TTL_MS / 1000}s. Run lasts ~15s.\n`);
|
|
94
|
+
|
|
95
|
+
process.stdout.write(" running WITHOUT refreshSession… ");
|
|
96
|
+
const before = await go({ supportsRefresh: false });
|
|
97
|
+
console.log("done");
|
|
98
|
+
process.stdout.write(" running WITH refreshSession… ");
|
|
99
|
+
const after = await go({ supportsRefresh: true });
|
|
100
|
+
console.log("done\n");
|
|
101
|
+
|
|
102
|
+
console.log(" " + "─".repeat(64));
|
|
103
|
+
row("", "no refreshSession", "with refresh");
|
|
104
|
+
console.log(" " + "─".repeat(64));
|
|
105
|
+
row("API calls attempted", before.api.calls, after.api.calls);
|
|
106
|
+
row("API calls FAILED", before.api.failures, after.api.failures);
|
|
107
|
+
row("failure rate", pct(before.api.failureRate), pct(after.api.failureRate));
|
|
108
|
+
row("location writes accepted", before.app.seen.locations, after.app.seen.locations);
|
|
109
|
+
row("posts accepted", before.app.seen.posts, after.app.seen.posts);
|
|
110
|
+
row("accounts cleaned up", before.teardown.removed, after.teardown.removed);
|
|
111
|
+
row("accounts LEFT BEHIND", before.teardown.failed.length, after.teardown.failed.length);
|
|
112
|
+
console.log(" " + "─".repeat(64));
|
|
113
|
+
|
|
114
|
+
const topBefore = before.api.methods.find((m) => m.failures)?.errors[0];
|
|
115
|
+
if (topBefore) console.log(`\n What the broken run reported: "${topBefore.message}"`);
|
|
116
|
+
console.log(
|
|
117
|
+
`\n Both runs kept ${before.world.agents.length} agents "alive" on screen the whole time.\n` +
|
|
118
|
+
` Only the numbers show that one of them stopped testing anything after ${TOKEN_TTL_MS / 1000}s.\n`,
|
|
119
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gigzen/populace",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A simulated population that uses your app through its real API, so you can test what needs more than one person.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"populace": "./src/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.mjs",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.mjs",
|
|
12
|
+
"./engine": "./src/engine/index.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"adapters",
|
|
17
|
+
"examples",
|
|
18
|
+
"populace.config.example.mjs",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"demo": "node src/cli.mjs demo",
|
|
24
|
+
"doctor": "node src/cli.mjs doctor",
|
|
25
|
+
"test": "node src/selftest.mjs",
|
|
26
|
+
"prepublishOnly": "node src/selftest.mjs"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"simulation",
|
|
33
|
+
"testing",
|
|
34
|
+
"load-testing",
|
|
35
|
+
"multi-user",
|
|
36
|
+
"synthetic-users",
|
|
37
|
+
"qa"
|
|
38
|
+
],
|
|
39
|
+
"license": "AGPL-3.0-only",
|
|
40
|
+
"dependencies": {},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@supabase/supabase-js": "^2.45.0"
|
|
43
|
+
},
|
|
44
|
+
"author": "Sankur Kundu <sankur.kundu.tw@gmail.com>",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/Shakhtar-Sankur/populace.git"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/Shakhtar-Sankur/populace#readme",
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/Shakhtar-Sankur/populace/issues"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|