@memberjunction/connector-wordpress 1.0.0 → 1.2.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/dist/WordPressConnector.js +7 -0
- package/dist/WordPressConnector.js.map +1 -1
- package/package.json +3 -2
- package/wp-plugin/mj-wsal-bridge/README.md +157 -0
- package/wp-plugin/mj-wsal-bridge/mj-wsal-bridge.php +1043 -0
- package/wp-plugin/mj-wsal-bridge/test/inspect-tables.mjs +147 -0
- package/wp-plugin/mj-wsal-bridge/test/probe.mjs +171 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* inspect-tables.mjs — what WP Activity Log data does THIS site actually hold?
|
|
4
|
+
*
|
|
5
|
+
* Answers the question that has to come before extending the connector: which `wsal_*` tables exist
|
|
6
|
+
* on a given install, what columns do they carry, and how many rows are in them. It authenticates
|
|
7
|
+
* exactly the way the MJ WordPress connector does — the REST root derived from the site's own
|
|
8
|
+
* `Link rel="https://api.w.org/"` header, then HTTP Basic with an Application Password — so a pass
|
|
9
|
+
* here means the connector's own auth path reaches this data, not merely that some HTTP call worked.
|
|
10
|
+
*
|
|
11
|
+
* WHY ASK RATHER THAN ASSUME
|
|
12
|
+
* Table presence is not a constant. The FREE plugin creates only `wsal_occurrences` and
|
|
13
|
+
* `wsal_metadata`; `wsal_sessions`, `wsal_custom_notifications`, `wsal_generated_reports` and
|
|
14
|
+
* `wsal_periodic_reports` arrive with premium extensions, and their columns vary by version.
|
|
15
|
+
* Declaring objects for tables a site does not have ships a catalog that silently returns nothing.
|
|
16
|
+
*
|
|
17
|
+
* Requires the MJ WSAL Bridge plugin (this directory) to be installed and activated on the site:
|
|
18
|
+
* WP Activity Log publishes no REST surface of its own, so without the bridge there is nothing to ask.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* WP_URL=https://example.org WP_USER=svc-mj WP_APP_PASSWORD='xxxx xxxx …' \
|
|
22
|
+
* node Platform/WordPress/wp-plugin/mj-wsal-bridge/test/inspect-tables.mjs
|
|
23
|
+
*
|
|
24
|
+
* Read-only: GET only. Reports structure and counts; no activity-log row content is read.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// People paste the URL they were looking at, which is usually wp-admin. Strip the well-known
|
|
28
|
+
// WordPress entry points so the site ROOT is what gets probed — the REST root is derived from the
|
|
29
|
+
// root's own Link header, and /wp-admin does not carry one.
|
|
30
|
+
const SITE = (process.env.WP_URL || '')
|
|
31
|
+
.trim()
|
|
32
|
+
.replace(/\/+$/, '')
|
|
33
|
+
.replace(/\/wp-admin(?:\/.*)?$/i, '')
|
|
34
|
+
.replace(/\/wp-login\.php.*$/i, '')
|
|
35
|
+
.replace(/\/wp-json\/?$/i, '')
|
|
36
|
+
.replace(/\/+$/, '');
|
|
37
|
+
const USER = process.env.WP_USER;
|
|
38
|
+
const PASS = process.env.WP_APP_PASSWORD;
|
|
39
|
+
|
|
40
|
+
if (!SITE || !USER || !PASS) {
|
|
41
|
+
console.error('Set WP_URL, WP_USER and WP_APP_PASSWORD (a WordPress Application Password).');
|
|
42
|
+
process.exit(2);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const AUTH = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64');
|
|
46
|
+
const redact = (s) => String(s ?? '').split(PASS).join('«redacted»').replace(/(Basic\s+)[A-Za-z0-9+/=]+/g, '$1«redacted»');
|
|
47
|
+
|
|
48
|
+
async function get(url, withAuth = true) {
|
|
49
|
+
const res = await fetch(url, { headers: withAuth ? { Authorization: AUTH } : {} });
|
|
50
|
+
const text = await res.text();
|
|
51
|
+
let body = null;
|
|
52
|
+
try { body = JSON.parse(text); } catch { /* non-JSON */ }
|
|
53
|
+
return { status: res.status, headers: res.headers, text, body };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The REST root is DERIVED, exactly as the connector does it — the prefix is filterable. */
|
|
57
|
+
async function restRoot() {
|
|
58
|
+
try {
|
|
59
|
+
const head = await fetch(SITE, { method: 'HEAD' });
|
|
60
|
+
const m = /<([^>]+)>;\s*rel="https:\/\/api\.w\.org\/"/i.exec(head.headers.get('link') ?? '');
|
|
61
|
+
if (m) return m[1];
|
|
62
|
+
} catch { /* fall through */ }
|
|
63
|
+
for (const c of [`${SITE}/wp-json/`, `${SITE}/?rest_route=/`]) {
|
|
64
|
+
try {
|
|
65
|
+
const r = await get(c, false);
|
|
66
|
+
if (r.status === 200 && r.text.trim().startsWith('{')) return c;
|
|
67
|
+
} catch { /* next */ }
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const root = await restRoot();
|
|
73
|
+
if (!root) {
|
|
74
|
+
console.error(`Could not reach the WordPress REST API at ${SITE}.`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const join = (p) => (root.includes('rest_route=') ? `${root}${p.replace(/^\//, '')}` : `${root.replace(/\/$/, '')}${p}`);
|
|
78
|
+
|
|
79
|
+
console.log(`\nSite: ${SITE}`);
|
|
80
|
+
console.log(`REST root: ${root}`);
|
|
81
|
+
|
|
82
|
+
// The bridge must be present, and this is the same namespace check the connector's discovery makes.
|
|
83
|
+
// Match on the PARSED namespaces array. PHP's json_encode escapes forward slashes, so the raw body
|
|
84
|
+
// contains "mj-wsal\/v1" — a regex for the unescaped form silently never matches on real WordPress.
|
|
85
|
+
const index = await get(root, false);
|
|
86
|
+
const namespaces = Array.isArray(index.body?.namespaces) ? index.body.namespaces : [];
|
|
87
|
+
if (!namespaces.includes('mj-wsal/v1')) {
|
|
88
|
+
console.error('\nThe MJ WSAL Bridge plugin is NOT installed or not activated on this site.');
|
|
89
|
+
console.error('WP Activity Log publishes no REST routes of its own, so without the bridge there is');
|
|
90
|
+
console.error('nothing to inspect. Install Platform/WordPress/wp-plugin/mj-wsal-bridge and activate it.\n');
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const res = await get(join('/mj-wsal/v1/tables'));
|
|
95
|
+
if (res.status === 401 || res.status === 403) {
|
|
96
|
+
console.error(`\nAuthentication failed (HTTP ${res.status}). The bridge requires an administrator.`);
|
|
97
|
+
console.error(redact(res.text).slice(0, 300));
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
if (res.status !== 200 || !res.body) {
|
|
101
|
+
console.error(`\nUnexpected HTTP ${res.status} from /mj-wsal/v1/tables.`);
|
|
102
|
+
console.error(redact(res.text).slice(0, 400));
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { base_prefix: prefix, present = [], missing = [] } = res.body;
|
|
107
|
+
console.log(`Prefix: ${prefix}\n`);
|
|
108
|
+
|
|
109
|
+
const SUPPORTED = new Set(['wsal_occurrences', 'wsal_metadata']);
|
|
110
|
+
|
|
111
|
+
console.log(`PRESENT — ${present.length} table(s)\n`);
|
|
112
|
+
for (const t of present) {
|
|
113
|
+
const mark = t.supported ? 'SUPPORTED ' : 'not yet ';
|
|
114
|
+
console.log(` ${mark}${t.suffix}`);
|
|
115
|
+
console.log(` ${t.rows.toLocaleString()} row(s), ${t.columns.length} column(s)`);
|
|
116
|
+
console.log(` ${t.note}`);
|
|
117
|
+
const cols = t.columns.map((c) => `${c.name}:${c.type}${c.key === 'PRI' ? ' [PK]' : ''}`);
|
|
118
|
+
// Wrap the column list rather than truncating it — the whole point is to see the real shape.
|
|
119
|
+
let line = ' ';
|
|
120
|
+
for (const c of cols) {
|
|
121
|
+
if (line.length + c.length > 110) { console.log(line); line = ' '; }
|
|
122
|
+
line += c + ' ';
|
|
123
|
+
}
|
|
124
|
+
if (line.trim()) console.log(line);
|
|
125
|
+
console.log('');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (missing.length) {
|
|
129
|
+
console.log(`ABSENT — ${missing.length} documented table(s) this site does not have\n`);
|
|
130
|
+
for (const m of missing) console.log(` ${m.suffix}\n ${m.note}`);
|
|
131
|
+
console.log('');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const extendable = present.filter((t) => !t.supported);
|
|
135
|
+
console.log('─'.repeat(70));
|
|
136
|
+
console.log(` ${present.length} present · ${present.filter((t) => t.supported).length} already supported · ${extendable.length} could be added · ${missing.length} absent`);
|
|
137
|
+
if (extendable.length) {
|
|
138
|
+
console.log(` candidates: ${extendable.map((t) => `${t.suffix} (${t.rows.toLocaleString()} rows)`).join(', ')}`);
|
|
139
|
+
const empty = extendable.filter((t) => t.rows === 0).map((t) => t.suffix);
|
|
140
|
+
if (empty.length) console.log(` note: ${empty.join(', ')} exist but hold NO rows — supporting them would add empty objects.`);
|
|
141
|
+
} else {
|
|
142
|
+
console.log(' nothing beyond what the connector already supports is present on this site.');
|
|
143
|
+
}
|
|
144
|
+
console.log('');
|
|
145
|
+
|
|
146
|
+
// Unsupported-but-populated tables are the only ones where extending the connector buys anything.
|
|
147
|
+
process.exit(extendable.some((t) => t.rows > 0) ? 0 : 0);
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* probe.mjs — live behavioural probe for the MJ WP Activity Log Bridge.
|
|
4
|
+
*
|
|
5
|
+
* Drives the bridge's real HTTP surface on a real WordPress install and asserts the properties the MJ
|
|
6
|
+
* WordPress connector actually depends on: discoverability, bounded pagination, a correct incremental
|
|
7
|
+
* watermark, and a schema for OPTIONS-based field discovery.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* WP_URL=http://localhost:8088 WP_USER=admin WP_APP_PASSWORD='xxxx xxxx …' \
|
|
11
|
+
* node Platform/WordPress/wp-plugin/mj-wsal-bridge/test/probe.mjs
|
|
12
|
+
*
|
|
13
|
+
* Read-only: it issues GET and OPTIONS only, and never writes to the site.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const BASE = (process.env.WP_URL || 'http://localhost:8088').replace(/\/$/, '');
|
|
17
|
+
const USER = process.env.WP_USER;
|
|
18
|
+
const PASS = process.env.WP_APP_PASSWORD;
|
|
19
|
+
|
|
20
|
+
if (!USER || !PASS) {
|
|
21
|
+
console.error('Set WP_USER and WP_APP_PASSWORD (a WordPress Application Password).');
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NS = `${BASE}/wp-json/mj-wsal/v1`;
|
|
26
|
+
const AUTH = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64');
|
|
27
|
+
|
|
28
|
+
const failures = [];
|
|
29
|
+
function check(name, ok, detail = '') {
|
|
30
|
+
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
|
|
31
|
+
if (!ok) failures.push(name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function req(url, method = 'GET', withAuth = true) {
|
|
35
|
+
const res = await fetch(url, { method, headers: withAuth ? { Authorization: AUTH } : {} });
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
let body = null;
|
|
38
|
+
try { body = JSON.parse(text); } catch { /* non-JSON body */ }
|
|
39
|
+
return { status: res.status, headers: res.headers, body };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── 0. Discovery: what the connector's route-index scan will see ─────────────
|
|
43
|
+
console.log('\n0. discovery (route index)');
|
|
44
|
+
{
|
|
45
|
+
const { body } = await req(`${BASE}/wp-json/`, 'GET', false);
|
|
46
|
+
check('mj-wsal/v1 is advertised as a namespace', (body?.namespaces || []).includes('mj-wsal/v1'));
|
|
47
|
+
const routes = body?.routes || {};
|
|
48
|
+
for (const path of ['/mj-wsal/v1/events', '/mj-wsal/v1/event-types']) {
|
|
49
|
+
const eps = routes[path]?.endpoints || [];
|
|
50
|
+
const listable = eps.some((e) => (e.methods || []).includes('GET') && 'per_page' in (e.args || {}));
|
|
51
|
+
// This is exactly the connector's discriminator: a GET collection route registering per_page.
|
|
52
|
+
check(`${path} is a listable GET collection (registers per_page)`, listable);
|
|
53
|
+
}
|
|
54
|
+
const rootEps = routes['/mj-wsal/v1']?.endpoints || [];
|
|
55
|
+
check('the namespace root is NOT mistaken for a collection',
|
|
56
|
+
!rootEps.some((e) => 'per_page' in (e.args || {})));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── 1. Auth ──────────────────────────────────────────────────────────────────
|
|
60
|
+
console.log('\n1. auth');
|
|
61
|
+
{
|
|
62
|
+
const anon = await req(`${NS}/events`, 'GET', false);
|
|
63
|
+
check('unauthenticated read is denied', anon.status === 401 || anon.status === 403, `HTTP ${anon.status}`);
|
|
64
|
+
const authed = await req(`${NS}/events?per_page=1`);
|
|
65
|
+
check('authenticated read succeeds', authed.status === 200, `HTTP ${authed.status}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── 2. Pagination is bounded and totally ordered ─────────────────────────────
|
|
69
|
+
console.log('\n2. pagination');
|
|
70
|
+
const all = (await req(`${NS}/events?per_page=100`)).body || [];
|
|
71
|
+
{
|
|
72
|
+
const first = await req(`${NS}/events?per_page=2&page=1`);
|
|
73
|
+
const totalPages = parseInt(first.headers.get('x-wp-totalpages') || '0', 10);
|
|
74
|
+
const total = parseInt(first.headers.get('x-wp-total') || '0', 10);
|
|
75
|
+
check('X-WP-Total / X-WP-TotalPages are emitted', total > 0 && totalPages > 0, `total=${total} pages=${totalPages}`);
|
|
76
|
+
|
|
77
|
+
const paged = [];
|
|
78
|
+
for (let p = 1; p <= totalPages; p++) {
|
|
79
|
+
const r = await req(`${NS}/events?per_page=2&page=${p}`);
|
|
80
|
+
paged.push(...(r.body || []).map((e) => e.id));
|
|
81
|
+
}
|
|
82
|
+
const single = all.map((e) => e.id);
|
|
83
|
+
check('paging yields the same rows as one big page', JSON.stringify(paged) === JSON.stringify(single),
|
|
84
|
+
`paged=${paged.length} single=${single.length}`);
|
|
85
|
+
check('no row is duplicated across page boundaries', new Set(paged).size === paged.length);
|
|
86
|
+
check('X-WP-Total agrees with the row count', total === single.length, `header=${total} rows=${single.length}`);
|
|
87
|
+
|
|
88
|
+
// The route must stay bounded: an oversized per_page is REJECTED, never honoured.
|
|
89
|
+
const over = await req(`${NS}/events?per_page=5000`);
|
|
90
|
+
check('per_page above the cap is rejected', over.status === 400, `HTTP ${over.status}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── 3. Incremental watermark — the property the sync path rests on ───────────
|
|
94
|
+
console.log('\n3. incremental watermark');
|
|
95
|
+
{
|
|
96
|
+
const mid = all[Math.floor(all.length / 2)];
|
|
97
|
+
const since = (await req(`${NS}/events?per_page=100&after=${encodeURIComponent(mid.created_at)}`)).body || [];
|
|
98
|
+
check('after=<created_at> INCLUDES the boundary row', since.some((e) => e.id === mid.id), `boundary id=${mid.id}`);
|
|
99
|
+
check('after returns nothing older than the bound',
|
|
100
|
+
since.every((e) => e.created_on >= Math.floor(mid.created_on * 1000) / 1000));
|
|
101
|
+
|
|
102
|
+
// THE core safety property. created_on is a double with microsecond precision, but created_at carries
|
|
103
|
+
// only milliseconds, so several events can share one watermark value. Re-syncing from the newest
|
|
104
|
+
// watermark must therefore return that row (and any co-timestamped sibling) — never zero rows, which
|
|
105
|
+
// would mean an event was skipped, and never the whole history, which would mean no progress at all.
|
|
106
|
+
const newest = all[all.length - 1];
|
|
107
|
+
const resync = (await req(`${NS}/events?per_page=100&after=${encodeURIComponent(newest.created_at)}`)).body || [];
|
|
108
|
+
const bucket = all.filter((e) => e.created_at === newest.created_at).length;
|
|
109
|
+
check('re-sync from the newest watermark never skips an event', resync.some((e) => e.id === newest.id));
|
|
110
|
+
check('re-sync re-delivers only the co-timestamped tail, not the history',
|
|
111
|
+
resync.length === bucket && resync.length < all.length, `redelivered=${resync.length} bucket=${bucket} total=${all.length}`);
|
|
112
|
+
|
|
113
|
+
const byEpoch = (await req(`${NS}/events?per_page=100&after=${mid.created_on}`)).body || [];
|
|
114
|
+
check('after also accepts a raw epoch', JSON.stringify(byEpoch.map((e) => e.id)) === JSON.stringify(since.map((e) => e.id)));
|
|
115
|
+
|
|
116
|
+
const bad = await req(`${NS}/events?after=not-a-date`);
|
|
117
|
+
check('an unparseable after is rejected', bad.status === 400, `HTTP ${bad.status}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── 4. Payload shape ─────────────────────────────────────────────────────────
|
|
121
|
+
console.log('\n4. payload');
|
|
122
|
+
{
|
|
123
|
+
const e = all[0];
|
|
124
|
+
check('created_on / created_at describe the same instant',
|
|
125
|
+
Math.abs(Date.parse(e.created_at) / 1000 - e.created_on) < 0.002,
|
|
126
|
+
`${e.created_on} vs ${e.created_at}`);
|
|
127
|
+
check('created_at is not the 1970 epoch-unit bug', new Date(e.created_at).getUTCFullYear() > 2000, e.created_at);
|
|
128
|
+
check('severity resolves to a name', all.every((x) => x.severity_label && x.severity_label !== ''),
|
|
129
|
+
JSON.stringify([...new Set(all.map((x) => `${x.severity}=${x.severity_label}`))]));
|
|
130
|
+
check('alert_label is resolved', all.every((x) => typeof x.alert_label === 'string'));
|
|
131
|
+
check('events carry pivoted metadata', all.some((x) => x.meta && Object.keys(x.meta).length > 0));
|
|
132
|
+
// A serialised stdClass (WSAL's PluginData) must arrive as real JSON, not an "O:8:…" string.
|
|
133
|
+
const structured = all.filter((x) => Object.values(x.meta || {}).some((v) => v && typeof v === 'object'));
|
|
134
|
+
const rawSerialized = all.filter((x) => Object.values(x.meta || {}).some((v) => typeof v === 'string' && /^[OaC]:\d+:/.test(v)));
|
|
135
|
+
check('serialised metadata is decoded to real structures', structured.length > 0, `${structured.length} row(s)`);
|
|
136
|
+
check('no metadata value leaks as a raw PHP-serialised string', rawSerialized.length === 0, `${rawSerialized.length} row(s)`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── 5. Catalog + join integrity ──────────────────────────────────────────────
|
|
140
|
+
console.log('\n5. event-type catalog');
|
|
141
|
+
{
|
|
142
|
+
const first = await req(`${NS}/event-types?per_page=100`);
|
|
143
|
+
const totalPages = parseInt(first.headers.get('x-wp-totalpages') || '0', 10);
|
|
144
|
+
const types = [];
|
|
145
|
+
for (let p = 1; p <= totalPages; p++) {
|
|
146
|
+
types.push(...((await req(`${NS}/event-types?per_page=100&page=${p}`)).body || []));
|
|
147
|
+
}
|
|
148
|
+
check('catalog is populated', types.length > 300, `${types.length} event types`);
|
|
149
|
+
const ids = new Set(types.map((t) => t.alert_id));
|
|
150
|
+
check('catalog ids are unique', ids.size === types.length);
|
|
151
|
+
const unresolved = all.map((e) => e.alert_id).filter((id) => !ids.has(id));
|
|
152
|
+
check('every logged alert_id resolves in the catalog', unresolved.length === 0, `unresolved=${JSON.stringify(unresolved)}`);
|
|
153
|
+
check('catalog rows carry label + category + severity',
|
|
154
|
+
types.slice(0, 25).every((t) => t.label && t.category && t.severity));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── 6. OPTIONS schema — what DiscoverFields reads ────────────────────────────
|
|
158
|
+
console.log('\n6. OPTIONS schema');
|
|
159
|
+
{
|
|
160
|
+
const { body } = await req(`${NS}/events`, 'OPTIONS');
|
|
161
|
+
const props = body?.schema?.properties || {};
|
|
162
|
+
check('OPTIONS advertises a field schema', Object.keys(props).length >= 20, `${Object.keys(props).length} properties`);
|
|
163
|
+
check('created_at is typed as a date-time', props.created_at?.format === 'date-time');
|
|
164
|
+
check('meta is typed as an object', props.meta?.type === 'object');
|
|
165
|
+
const payloadKeys = Object.keys(all[0] || {});
|
|
166
|
+
const undeclared = payloadKeys.filter((k) => !(k in props));
|
|
167
|
+
check('every payload field is declared in the schema', undeclared.length === 0, `undeclared=${JSON.stringify(undeclared)}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log(failures.length ? `\n${failures.length} FAILURE(S): ${JSON.stringify(failures)}\n` : '\nALL PASS\n');
|
|
171
|
+
process.exit(failures.length ? 1 : 0);
|