@nextcommerce/campaigns-os 1.37.3 → 1.41.2
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/AGENTS.md +114 -10
- package/CHANGELOG.md +530 -0
- package/README.md +38 -27
- package/contracts/agent-relevant-change-policy.v1.json +11 -1
- package/contracts/effects.v1.json +4794 -0
- package/contracts/release-ledger.json +789 -0
- package/contracts/supported-surface.json +25 -5
- package/docs/build-packet.md +27 -16
- package/docs/demo-preview.md +1 -1
- package/docs/diagnostics.md +7 -4
- package/docs/effects.md +281 -0
- package/docs/gateway-login.md +113 -0
- package/docs/orientation-contract-reference.md +4 -1
- package/docs/progress-snapshots.md +3 -3
- package/docs/qa-and-test-orders.md +3 -3
- package/docs/readback.md +523 -0
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +364 -0
- package/docs/supported-surface.md +11 -3
- package/docs/versioning.md +8 -4
- package/package.json +8 -3
- package/schemas/campaign-runtime-build-packet.v0.schema.json +5 -0
- package/schemas/campaigns-os-effects.v1.schema.json +211 -0
- package/schemas/campaigns-os-readback.v2.schema.json +267 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +174 -0
- package/skills/campaign-readback-classification/SKILL.md +230 -0
- package/skills/campaign-run-evidence/SKILL.md +140 -0
- package/skills/contribution-intake/SKILL.md +85 -0
- package/skills/next-campaigns-build/SKILL.md +33 -12
- package/skills/next-campaigns-os/SKILL.md +45 -21
- package/skills/next-campaigns-os-setup/SKILL.md +35 -14
- package/skills/next-campaigns-polish/SKILL.md +43 -17
- package/skills/next-campaigns-qa/SKILL.md +48 -24
- package/skills.json +39 -6
- package/src/admin-transport.mjs +123 -0
- package/src/cli.mjs +991 -200
- package/src/credential-store.mjs +183 -0
- package/src/deviation.mjs +3 -2
- package/src/diagnostic.mjs +4 -1
- package/src/gate-actions.mjs +2 -2
- package/src/install-mode.mjs +17 -9
- package/src/lifecycle.mjs +95 -0
- package/src/login.mjs +152 -0
- package/src/package-install-fixture.mjs +3 -2
- package/src/qa-node.mjs +56 -19
- package/src/qa-publish.mjs +108 -2
- package/src/readback.mjs +1936 -0
- package/src/remit.mjs +17 -3
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createCredentialStore } from './credential-store.mjs';
|
|
2
|
+
import { GATEWAY, CLIENT_ID, RESOURCE, SCOPE, canonicalLoginStore, gatewayRequest, credentialFromResponse } from './login.mjs';
|
|
3
|
+
|
|
4
|
+
const MAX_BYTES = 10 * 1048576; // custody consolidates at most ten bounded pages
|
|
5
|
+
const MAX_ROWS = 2000;
|
|
6
|
+
const REQUEST_MS = 15000, BUDGET_MS = 45000;
|
|
7
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
const opaque = value => typeof value === 'string' && value.length > 0 && value.length <= 8192 && !/[\u0000-\u001f\u007f]/u.test(value);
|
|
9
|
+
class ReadFailure extends Error {
|
|
10
|
+
constructor(status, detail) { super(detail); this.status = status; }
|
|
11
|
+
}
|
|
12
|
+
const storageUnavailable = () => new ReadFailure('credential_unavailable', 'Gateway credential storage is unavailable or busy. Check your user credential directory permissions and keychain access; wait for another campaigns-os process to finish. For an interrupted process, confirm no campaigns-os process is running before removing its stale .lock directory. No environment fallback was attempted.');
|
|
13
|
+
const loginRequired = () => new ReadFailure('unauthorized', 'Gateway login is expired, revoked, or needs recovery. Run campaigns-os login --store <subdomain>.');
|
|
14
|
+
const bindingFor = store => ({ gateway: GATEWAY, client_id: CLIENT_ID, store });
|
|
15
|
+
function validate(record, binding) {
|
|
16
|
+
if (!record) throw new ReadFailure('credential_missing', 'No gateway login for this store. Run campaigns-os login --store <subdomain>.');
|
|
17
|
+
if (record.gateway !== binding.gateway || record.client_id !== binding.client_id || record.store !== binding.store || record.resource !== RESOURCE || record.scope !== SCOPE || record.schema_version !== 1 || record.token_type !== 'Bearer' || !opaque(record.access_token) || !opaque(record.refresh_token) || !opaque(record.grant_id) || !Number.isFinite(record.access_expires_at) || record.refresh_pending) throw loginRequired();
|
|
18
|
+
return record;
|
|
19
|
+
}
|
|
20
|
+
async function readJson(route, token, { fetchImpl, timeoutMs }) {
|
|
21
|
+
const controller = new AbortController(); let timer, reader;
|
|
22
|
+
const run = async () => {
|
|
23
|
+
const response = await fetchImpl(GATEWAY + route, { method: 'GET', headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, redirect: 'error', signal: controller.signal });
|
|
24
|
+
if (response.redirected || (response.url && response.url !== GATEWAY + route)) throw new ReadFailure('invalid', 'Gateway returned an unexpected redirect.');
|
|
25
|
+
if (response.status === 401) throw Object.assign(loginRequired(), { httpStatus: 401 });
|
|
26
|
+
if (response.status === 403) throw loginRequired();
|
|
27
|
+
if (response.status === 404) throw new ReadFailure('not_found', 'Gateway read route or store is unavailable.');
|
|
28
|
+
if (!response.ok) throw new ReadFailure('unreachable', 'Gateway read failed. No environment credential fallback was attempted.');
|
|
29
|
+
if (response.headers.get('content-type')?.split(';')[0] !== 'application/json') throw new ReadFailure('invalid', 'Gateway did not return a JSON profile.');
|
|
30
|
+
reader = response.body?.getReader(); if (!reader) throw new ReadFailure('invalid', 'Gateway returned an empty profile.');
|
|
31
|
+
const chunks = []; let size = 0;
|
|
32
|
+
for (;;) { const { value, done } = await reader.read(); if (done) break; size += value.byteLength; if (size > MAX_BYTES) throw new ReadFailure('invalid', 'Gateway response exceeded the read bound.'); chunks.push(value); }
|
|
33
|
+
let body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new ReadFailure('invalid', 'Gateway response was not valid JSON.'); }
|
|
34
|
+
if (!object(body)) throw new ReadFailure('invalid', 'Gateway response was not the documented object.');
|
|
35
|
+
return body;
|
|
36
|
+
};
|
|
37
|
+
try {
|
|
38
|
+
if (timeoutMs <= 0) throw new ReadFailure('unreachable', 'Gateway read budget expired.');
|
|
39
|
+
return await Promise.race([run(), new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new ReadFailure('unreachable', 'Gateway read timed out.')); }, Math.min(REQUEST_MS, timeoutMs)); })]);
|
|
40
|
+
} catch (error) { throw error instanceof ReadFailure ? error : new ReadFailure('unreachable', 'Gateway is unreachable. No environment credential fallback was attempted.'); }
|
|
41
|
+
finally { clearTimeout(timer); controller.abort(); void reader?.cancel().catch(() => {}); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Caller owns the exclusive credential transaction. Logout uses this same
|
|
45
|
+
// durable consumption boundary, including when interrupted before cleanup.
|
|
46
|
+
export async function rotateLockedCredential(storage, current, { fetchImpl = globalThis.fetch, now = Date.now, timeoutMs = REQUEST_MS } = {}) {
|
|
47
|
+
if (current.refresh_pending) throw loginRequired();
|
|
48
|
+
storage.write({ ...current, refresh_pending: true });
|
|
49
|
+
try {
|
|
50
|
+
const started = now();
|
|
51
|
+
const data = await gatewayRequest('/token', { fetchImpl, timeoutMs, form: { grant_type: 'refresh_token', client_id: CLIENT_ID, resource: RESOURCE, refresh_token: current.refresh_token } });
|
|
52
|
+
if (data.expires_in === 0) throw new ReadFailure('unauthorized', 'Gateway grant has expired. Run campaigns-os login --store <subdomain>.');
|
|
53
|
+
if (data.grant_id !== current.grant_id) throw loginRequired();
|
|
54
|
+
const next = credentialFromResponse(data, current.store, started);
|
|
55
|
+
storage.write(next);
|
|
56
|
+
return next;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof ReadFailure) throw error;
|
|
59
|
+
const failure = loginRequired(); failure.httpStatus = error?.status; throw failure;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function readGatewayStoreProfile({ subdomain, credentials = createCredentialStore(), fetchImpl = globalThis.fetch, now = Date.now, budgetMs = BUDGET_MS, timeoutMs = REQUEST_MS } = {}) {
|
|
64
|
+
let binding;
|
|
65
|
+
try { binding = bindingFor(canonicalLoginStore(subdomain)); } catch { return { status: 'invalid', detail: 'Invalid store for gateway login.' }; }
|
|
66
|
+
const deadline = now() + Math.min(BUDGET_MS, budgetMs);
|
|
67
|
+
const remaining = () => Math.min(timeoutMs, deadline - now());
|
|
68
|
+
let record, refreshUsed = false;
|
|
69
|
+
const refresh = async loaded => {
|
|
70
|
+
if (refreshUsed) throw loginRequired();
|
|
71
|
+
refreshUsed = true;
|
|
72
|
+
const outcome = await credentials.transaction(binding, async storage => {
|
|
73
|
+
try {
|
|
74
|
+
const current = validate(storage.read(), binding);
|
|
75
|
+
if (current.refresh_token !== loaded.refresh_token || current.access_token !== loaded.access_token) return { record: current };
|
|
76
|
+
if (remaining() <= 0) throw new ReadFailure('unreachable', 'Gateway read budget expired before refresh.');
|
|
77
|
+
return { record: await rotateLockedCredential(storage, current, { fetchImpl, now, timeoutMs: remaining() }) };
|
|
78
|
+
} catch (error) { return { failure: error instanceof ReadFailure ? error : storageUnavailable() }; }
|
|
79
|
+
});
|
|
80
|
+
if (outcome.failure) throw outcome.failure;
|
|
81
|
+
return outcome.record;
|
|
82
|
+
};
|
|
83
|
+
const request = async route => {
|
|
84
|
+
if (record.access_expires_at <= now() + 60000) record = await refresh(record);
|
|
85
|
+
try { return await readJson(route, record.access_token, { fetchImpl, timeoutMs: remaining() }); }
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error.httpStatus !== 401) throw error;
|
|
88
|
+
record = await refresh(record);
|
|
89
|
+
return readJson(route, record.access_token, { fetchImpl, timeoutMs: remaining() });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
record = validate(await credentials.read(binding), binding);
|
|
94
|
+
const store = await request('/admin/store/');
|
|
95
|
+
// A projected sparse store is legitimate: pure derivation explains every
|
|
96
|
+
// missing field. Do not invent required profile fields here.
|
|
97
|
+
try {
|
|
98
|
+
const body = await request('/admin/pages/');
|
|
99
|
+
if (!Array.isArray(body.results) || body.results.length > MAX_ROWS || Object.hasOwn(body, 'next') || body.results.some(row => !object(row) || (row.slug != null && typeof row.slug !== 'string') || (row.title != null && typeof row.title !== 'string'))) throw new ReadFailure('invalid', 'Gateway returned an invalid consolidated page list.');
|
|
100
|
+
return { status: 'ok', store, pages: body.results.filter(row => typeof row.slug === 'string' && row.slug.trim()).map(({ slug, title }) => ({ slug, title: typeof title === 'string' ? title : '' })), pages_status: 'ok', pages_detail: null };
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return { status: 'ok', store, pages: null, pages_status: 'unavailable', pages_detail: error instanceof ReadFailure ? error.message : storageUnavailable().message };
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const failure = error instanceof ReadFailure ? error : storageUnavailable();
|
|
106
|
+
return { status: failure.status, detail: failure.message };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function gatewayLoginStatus({ credentials = createCredentialStore(), now = Date.now } = {}) {
|
|
111
|
+
try {
|
|
112
|
+
const bindings = await credentials.listBindings();
|
|
113
|
+
const accounts = [];
|
|
114
|
+
for (const binding of bindings) {
|
|
115
|
+
if (binding.gateway !== GATEWAY || binding.client_id !== CLIENT_ID) continue;
|
|
116
|
+
const record = await credentials.read(binding);
|
|
117
|
+
try { validate(record, binding); }
|
|
118
|
+
catch { accounts.push({ store: canonicalLoginStore(binding.store), state: 'login_required', access_expires_at: null, remaining_seconds: 0, gateway_version: null }); continue; }
|
|
119
|
+
accounts.push({ store: binding.store, state: record.access_expires_at > now() ? 'logged_in' : 'access_expired', access_expires_at: record.access_expires_at, remaining_seconds: Math.max(0, Math.floor((record.access_expires_at - now()) / 1000)), gateway_version: typeof record.gateway_version === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._+-]{0,127}$/.test(record.gateway_version) && ![record.access_token, record.refresh_token].some(token => record.gateway_version.includes(token)) ? record.gateway_version : null });
|
|
120
|
+
}
|
|
121
|
+
return { gateway: GATEWAY, checked: 'local_only', state: accounts.length ? 'available' : 'logged_out', accounts };
|
|
122
|
+
} catch { return { gateway: GATEWAY, checked: 'local_only', state: 'unavailable', accounts: [] }; }
|
|
123
|
+
}
|