@aiguru/google-web-operations 0.6.4
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/.env.example +42 -0
- package/README.md +110 -0
- package/apps/cli/src/index.js +286 -0
- package/apps/mcp-server/src/index.js +360 -0
- package/apps/worker/src/index.js +96 -0
- package/apps/worker/src/loop.js +46 -0
- package/bin/gwo.js +67 -0
- package/docs/BOOTSTRAP-NEXT-PROMPT.md +57 -0
- package/docs/GETTING-STARTED.he.md +63 -0
- package/docs/HOSTED-WORKER.md +36 -0
- package/docs/INSTALL.he.md +57 -0
- package/docs/PRODUCT-TECHNICAL-SPEC-v0.2.md +153 -0
- package/docs/PRODUCT-TECHNICAL-SPEC-v0.3.md +86 -0
- package/docs/PRODUCT-TECHNICAL-SPEC-v0.4.md +92 -0
- package/docs/PRODUCT-TECHNICAL-SPEC-v0.5.md +94 -0
- package/docs/PRODUCT-TECHNICAL-SPEC-v0.6.md +98 -0
- package/docs/SECURITY.md +69 -0
- package/docs/SITE-ADAPTERS.md +87 -0
- package/docs/adr/ADR-001-shared-mcp-core.md +27 -0
- package/docs/adr/ADR-002-policy-gated-writes.md +18 -0
- package/docs/adr/ADR-003-single-source-playbooks.md +18 -0
- package/docs/adr/ADR-004-native-typescript-runtime.md +16 -0
- package/docs/adr/ADR-005-two-token-publish.md +22 -0
- package/docs/adr/ADR-006-one-package-one-installer.md +20 -0
- package/hosts/claude/.claude-plugin/plugin.json +9 -0
- package/hosts/claude/.mcp.json +10 -0
- package/hosts/claude/README.md +7 -0
- package/hosts/claude/commands/gwo-add-site.md +7 -0
- package/hosts/claude/commands/gwo-daily.md +7 -0
- package/hosts/claude/commands/gwo-measure.md +7 -0
- package/hosts/claude/commands/gwo-monthly.md +7 -0
- package/hosts/claude/commands/gwo-onboard.md +7 -0
- package/hosts/claude/commands/gwo-scan.md +7 -0
- package/hosts/claude/commands/gwo-setup.md +7 -0
- package/hosts/claude/commands/gwo-triage.md +7 -0
- package/hosts/claude/commands/gwo-weekly.md +7 -0
- package/hosts/claude/skills/gwo-client-report-template/SKILL.md +36 -0
- package/hosts/claude/skills/gwo-content-brief/SKILL.md +60 -0
- package/hosts/claude/skills/gwo-daily/SKILL.md +47 -0
- package/hosts/claude/skills/gwo-measurement/SKILL.md +92 -0
- package/hosts/claude/skills/gwo-monthly/SKILL.md +30 -0
- package/hosts/claude/skills/gwo-onboarding/SKILL.md +27 -0
- package/hosts/claude/skills/gwo-operating-model/SKILL.md +76 -0
- package/hosts/claude/skills/gwo-setup/SKILL.md +42 -0
- package/hosts/claude/skills/gwo-triage/SKILL.md +37 -0
- package/hosts/claude/skills/gwo-weekly/SKILL.md +48 -0
- package/hosts/codex/.codex/config.template.toml +5 -0
- package/hosts/codex/.codex-plugin/plugin.json +30 -0
- package/hosts/codex/AGENTS.md +470 -0
- package/hosts/codex/README.md +13 -0
- package/hosts/codex/skills/gwo-client-report-template/SKILL.md +36 -0
- package/hosts/codex/skills/gwo-content-brief/SKILL.md +60 -0
- package/hosts/codex/skills/gwo-daily/SKILL.md +47 -0
- package/hosts/codex/skills/gwo-measurement/SKILL.md +92 -0
- package/hosts/codex/skills/gwo-monthly/SKILL.md +30 -0
- package/hosts/codex/skills/gwo-onboarding/SKILL.md +27 -0
- package/hosts/codex/skills/gwo-operating-model/SKILL.md +76 -0
- package/hosts/codex/skills/gwo-setup/SKILL.md +42 -0
- package/hosts/codex/skills/gwo-triage/SKILL.md +37 -0
- package/hosts/codex/skills/gwo-weekly/SKILL.md +48 -0
- package/package.json +24 -0
- package/packages/connectors/browser/src/index.js +122 -0
- package/packages/connectors/docx/src/index.js +122 -0
- package/packages/connectors/github/src/index.js +102 -0
- package/packages/connectors/google/src/auth.js +73 -0
- package/packages/connectors/google/src/discover.js +135 -0
- package/packages/connectors/google/src/ga4.js +42 -0
- package/packages/connectors/google/src/gsc.js +49 -0
- package/packages/connectors/google/src/gtm.js +261 -0
- package/packages/connectors/google/src/index.js +8 -0
- package/packages/connectors/google/src/oauth.js +198 -0
- package/packages/connectors/google/src/pagespeed.js +48 -0
- package/packages/connectors/google/src/token-store.js +89 -0
- package/packages/connectors/odoo/src/index.js +206 -0
- package/packages/connectors/repo/src/index.js +94 -0
- package/packages/connectors/resend/src/index.js +30 -0
- package/packages/connectors/web-crawler/src/a11y.js +91 -0
- package/packages/connectors/web-crawler/src/fetch.js +165 -0
- package/packages/connectors/web-crawler/src/index.js +69 -0
- package/packages/connectors/web-crawler/src/parse.js +157 -0
- package/packages/core/src/adapters.js +27 -0
- package/packages/core/src/admin.js +232 -0
- package/packages/core/src/content.js +213 -0
- package/packages/core/src/doctor.js +39 -0
- package/packages/core/src/escalate.js +36 -0
- package/packages/core/src/gtm-ops.js +311 -0
- package/packages/core/src/index.js +13 -0
- package/packages/core/src/operations.js +239 -0
- package/packages/core/src/paths.js +46 -0
- package/packages/core/src/registry.js +114 -0
- package/packages/core/src/report/monthly.js +272 -0
- package/packages/core/src/scan/render.js +40 -0
- package/packages/core/src/scan/rules.js +289 -0
- package/packages/core/src/scan/suppress.js +37 -0
- package/packages/core/src/types.js +10 -0
- package/packages/core/src/writes.js +50 -0
- package/packages/playbooks/00-operating-model.md +74 -0
- package/packages/playbooks/10-daily.md +45 -0
- package/packages/playbooks/20-weekly.md +46 -0
- package/packages/playbooks/30-monthly.md +28 -0
- package/packages/playbooks/40-triage.md +35 -0
- package/packages/playbooks/50-onboarding.md +25 -0
- package/packages/playbooks/60-content-brief.md +58 -0
- package/packages/playbooks/70-measurement.md +90 -0
- package/packages/playbooks/80-setup.md +40 -0
- package/packages/playbooks/90-client-report-template.md +34 -0
- package/packages/policy/src/index.js +115 -0
- package/packages/storage/src/index.js +131 -0
- package/policies/default.yaml +90 -0
- package/sites/schema.yaml +71 -0
- package/sites/secrets.example.yaml +6 -0
- package/tools/build-hosts.js +118 -0
- package/tools/build-package.js +43 -0
- package/tools/install.js +145 -0
- package/tools/schedule.ps1 +37 -0
- package/tools/smoke-mcp.js +61 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Tag Manager API v2.
|
|
3
|
+
*
|
|
4
|
+
* Reads (v0.3): accounts, containers, workspaces, version headers, the live
|
|
5
|
+
* version, and a compact diffable snapshot of it.
|
|
6
|
+
*
|
|
7
|
+
* Writes (v0.4): staged only. Every change lands in a workspace whose name
|
|
8
|
+
* starts with the policy prefix, the workspace is diffed against the live
|
|
9
|
+
* version, and publishing requires both the operator's approval token and a
|
|
10
|
+
* review token computed from that diff, so nothing publishes that was not
|
|
11
|
+
* seen. Functions here are transport only; the write loop, policy and
|
|
12
|
+
* verification live in packages/core/src/gtm-ops.ts.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { googleJson } from "./auth.js";
|
|
16
|
+
const V2 = 'https://tagmanager.googleapis.com/tagmanager/v2';
|
|
17
|
+
export const ID_FIELD = { tag: 'tagId', trigger: 'triggerId', variable: 'variableId' };
|
|
18
|
+
const COLLECTION = { tag: 'tags', trigger: 'triggers', variable: 'variables' };
|
|
19
|
+
/** Fields the API owns. Never sent on create or update. */
|
|
20
|
+
const READ_ONLY = new Set(['path', 'accountId', 'containerId', 'workspaceId', 'tagId', 'triggerId', 'variableId', 'fingerprint', 'tagManagerUrl']);
|
|
21
|
+
/** Triggers every container has without creating them. Names as GTM shows them. */
|
|
22
|
+
export const BUILT_IN_TRIGGERS = {
|
|
23
|
+
'All Pages': '2147479553',
|
|
24
|
+
'Consent Initialization - All Pages': '2147479572',
|
|
25
|
+
'Initialization - All Pages': '2147479573',
|
|
26
|
+
};
|
|
27
|
+
const containerPath = (a, c) => `${V2}/accounts/${a}/containers/${c}`;
|
|
28
|
+
const wsPath = (a, c, w) => `${containerPath(a, c)}/workspaces/${w}`;
|
|
29
|
+
export async function listAccounts() {
|
|
30
|
+
return (await googleJson(`${V2}/accounts`)).account ?? [];
|
|
31
|
+
}
|
|
32
|
+
export async function listContainers(accountId) {
|
|
33
|
+
return (await googleJson(`${V2}/accounts/${accountId}/containers`)).container ?? [];
|
|
34
|
+
}
|
|
35
|
+
/** Every container the account can see, across all GTM accounts. */
|
|
36
|
+
export async function listAllContainers() {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const a of await listAccounts())
|
|
39
|
+
out.push(...(await listContainers(a.accountId)));
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
export async function listWorkspaces(accountId, containerId) {
|
|
43
|
+
return (await googleJson(`${containerPath(accountId, containerId)}/workspaces`)).workspace ?? [];
|
|
44
|
+
}
|
|
45
|
+
export async function listVersionHeaders(accountId, containerId) {
|
|
46
|
+
return (await googleJson(`${containerPath(accountId, containerId)}/version_headers`)).containerVersionHeader ?? [];
|
|
47
|
+
}
|
|
48
|
+
export async function getLiveVersion(accountId, containerId) {
|
|
49
|
+
return googleJson(`${containerPath(accountId, containerId)}/versions:live`);
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------- workspaces (write side)
|
|
52
|
+
export function createWorkspace(accountId, containerId, name, description) {
|
|
53
|
+
return googleJson(`${containerPath(accountId, containerId)}/workspaces`, { method: 'POST', body: JSON.stringify({ name, description }) });
|
|
54
|
+
}
|
|
55
|
+
/** What the workspace would change relative to its base version, straight from GTM. */
|
|
56
|
+
export function workspaceStatus(accountId, containerId, workspaceId) {
|
|
57
|
+
return googleJson(`${wsPath(accountId, containerId, workspaceId)}/status`);
|
|
58
|
+
}
|
|
59
|
+
/** Bring the workspace up to the latest container version. Needed before creating a version when others published meanwhile. */
|
|
60
|
+
export function syncWorkspace(accountId, containerId, workspaceId) {
|
|
61
|
+
return googleJson(`${wsPath(accountId, containerId, workspaceId)}:sync`, { method: 'POST' });
|
|
62
|
+
}
|
|
63
|
+
export async function listEntities(accountId, containerId, workspaceId, kind) {
|
|
64
|
+
const out = [];
|
|
65
|
+
let pageToken;
|
|
66
|
+
do {
|
|
67
|
+
const url = `${wsPath(accountId, containerId, workspaceId)}/${COLLECTION[kind]}${pageToken ? `?pageToken=${encodeURIComponent(pageToken)}` : ''}`;
|
|
68
|
+
const page = (await googleJson(url)) ?? {};
|
|
69
|
+
out.push(...(page[kind] ?? []));
|
|
70
|
+
pageToken = page['nextPageToken'];
|
|
71
|
+
} while (pageToken);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/** Strip API-owned fields so a body can be sent on create or update. */
|
|
75
|
+
export function writableBody(entity) {
|
|
76
|
+
return Object.fromEntries(Object.entries(entity).filter(([k]) => !READ_ONLY.has(k)));
|
|
77
|
+
}
|
|
78
|
+
export function createEntity(accountId, containerId, workspaceId, kind, body) {
|
|
79
|
+
return googleJson(`${wsPath(accountId, containerId, workspaceId)}/${COLLECTION[kind]}`, { method: 'POST', body: JSON.stringify(writableBody(body)) });
|
|
80
|
+
}
|
|
81
|
+
/** Full replace, as the API requires. The fingerprint guards against overwriting a concurrent edit. */
|
|
82
|
+
export function updateEntity(accountId, containerId, workspaceId, kind, id, body, fingerprint) {
|
|
83
|
+
const q = fingerprint ? `?fingerprint=${encodeURIComponent(fingerprint)}` : '';
|
|
84
|
+
return googleJson(`${wsPath(accountId, containerId, workspaceId)}/${COLLECTION[kind]}/${id}${q}`, { method: 'PUT', body: JSON.stringify(writableBody(body)) });
|
|
85
|
+
}
|
|
86
|
+
/** Turn the workspace into a container version. GTM deletes the workspace and opens a fresh one in its place. */
|
|
87
|
+
export function createVersion(accountId, containerId, workspaceId, body) {
|
|
88
|
+
return googleJson(`${wsPath(accountId, containerId, workspaceId)}:create_version`, { method: 'POST', body: JSON.stringify(body) });
|
|
89
|
+
}
|
|
90
|
+
export function publishVersion(accountId, containerId, versionId) {
|
|
91
|
+
return googleJson(`${containerPath(accountId, containerId)}/versions/${versionId}:publish`, { method: 'POST' });
|
|
92
|
+
}
|
|
93
|
+
// ---------------------------------------------------------------- pure helpers for staging
|
|
94
|
+
/** Merge a patch over an existing entity for a PUT. Read-only fields are dropped; explicit null removes a field. */
|
|
95
|
+
export function mergeEntity(existing, patch) {
|
|
96
|
+
const merged = { ...writableBody(existing) };
|
|
97
|
+
for (const [k, v] of Object.entries(writableBody(patch))) {
|
|
98
|
+
if (v === null)
|
|
99
|
+
delete merged[k];
|
|
100
|
+
else
|
|
101
|
+
merged[k] = v;
|
|
102
|
+
}
|
|
103
|
+
return merged;
|
|
104
|
+
}
|
|
105
|
+
const paramMap = (ps) => Object.fromEntries((ps ?? []).filter((p) => p.key).map((p) => [p.key, JSON.stringify({ t: p.type, v: p.value ?? null, l: p.list ?? null, m: p.map ?? null })]));
|
|
106
|
+
/**
|
|
107
|
+
* Did the API store what was sent? Compares the fields a request can set and
|
|
108
|
+
* this system cares about. Returns a description of the first mismatch, or null.
|
|
109
|
+
*/
|
|
110
|
+
export function entityMatches(sent, stored) {
|
|
111
|
+
if (!stored)
|
|
112
|
+
return 'entity not found after write';
|
|
113
|
+
for (const k of ['name', 'type', 'paused']) {
|
|
114
|
+
if (sent[k] !== undefined && sent[k] !== stored[k])
|
|
115
|
+
return `${k}: sent ${JSON.stringify(sent[k])}, stored ${JSON.stringify(stored[k])}`;
|
|
116
|
+
}
|
|
117
|
+
for (const k of ['firingTriggerId', 'blockingTriggerId']) {
|
|
118
|
+
if (sent[k] !== undefined && JSON.stringify([...sent[k] ?? []].sort()) !== JSON.stringify([...stored[k] ?? []].sort()))
|
|
119
|
+
return `${k}: sent ${JSON.stringify(sent[k])}, stored ${JSON.stringify(stored[k] ?? [])}`;
|
|
120
|
+
}
|
|
121
|
+
if (sent.parameter) {
|
|
122
|
+
const want = paramMap(sent.parameter), got = paramMap(stored.parameter);
|
|
123
|
+
for (const [key, v] of Object.entries(want))
|
|
124
|
+
if (got[key] !== v)
|
|
125
|
+
return `parameter ${key} differs after write`;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
function changedFields(live, ws) {
|
|
130
|
+
if (!live)
|
|
131
|
+
return [];
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const k of ['name', 'type', 'paused'])
|
|
134
|
+
if ((live[k] ?? null) !== (ws[k] ?? null))
|
|
135
|
+
out.push(k);
|
|
136
|
+
for (const k of ['firingTriggerId', 'blockingTriggerId'])
|
|
137
|
+
if (JSON.stringify(live[k] ?? []) !== JSON.stringify(ws[k] ?? []))
|
|
138
|
+
out.push(k);
|
|
139
|
+
if (JSON.stringify(paramMap(live.parameter)) !== JSON.stringify(paramMap(ws.parameter)))
|
|
140
|
+
out.push('parameter');
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
/** Shape a workspace status against the live version into a reviewable diff. Pure. */
|
|
144
|
+
export function shapeWorkspaceDiff(status, live) {
|
|
145
|
+
const liveById = {
|
|
146
|
+
tag: new Map((live.tag ?? []).map((t) => [t.tagId, t])),
|
|
147
|
+
trigger: new Map((live.trigger ?? []).map((t) => [t.triggerId, t])),
|
|
148
|
+
variable: new Map((live.variable ?? []).map((v) => [v.variableId, v])),
|
|
149
|
+
};
|
|
150
|
+
const changes = [];
|
|
151
|
+
for (const c of status.workspaceChange ?? []) {
|
|
152
|
+
if (c.changeStatus === 'none')
|
|
153
|
+
continue;
|
|
154
|
+
const kind = c.tag ? 'tag' : c.trigger ? 'trigger' : c.variable ? 'variable' : null;
|
|
155
|
+
if (kind) {
|
|
156
|
+
const e = c[kind];
|
|
157
|
+
const id = e[ID_FIELD[kind]] ?? null;
|
|
158
|
+
changes.push({
|
|
159
|
+
kind, changeStatus: c.changeStatus, id, name: e.name, type: e.type ?? null, fingerprint: e.fingerprint ?? null,
|
|
160
|
+
fields: c.changeStatus === 'updated' && id ? changedFields(liveById[kind].get(id), e) : [],
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const other = c.folder ?? c.client ?? c.transformation ?? c.template ?? c.zone ?? c.gtagConfig;
|
|
165
|
+
changes.push({ kind: 'other', changeStatus: c.changeStatus, id: null, name: other?.name ?? '(unnamed)', type: null, fingerprint: null, fields: [] });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
changes.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
|
|
169
|
+
const count = (s) => changes.filter((c) => c.changeStatus === s).length;
|
|
170
|
+
return {
|
|
171
|
+
liveVersionId: live.containerVersionId, changes,
|
|
172
|
+
counts: { added: count('added'), updated: count('updated'), deleted: count('deleted'), other: changes.filter((c) => c.kind === 'other').length },
|
|
173
|
+
conflicts: status.mergeConflict?.length ?? 0,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Short token bound to exactly this set of changes. Any further edit in the
|
|
178
|
+
* workspace, or a new live version, changes the token, so a publish that
|
|
179
|
+
* carries a token proves the operator reviewed the diff that will ship.
|
|
180
|
+
*/
|
|
181
|
+
export function reviewToken(diff) {
|
|
182
|
+
const material = JSON.stringify({ live: diff.liveVersionId, changes: diff.changes.map((c) => [c.kind, c.changeStatus, c.id, c.name, c.fingerprint]) });
|
|
183
|
+
return createHash('sha256').update(material).digest('hex').slice(0, 12);
|
|
184
|
+
}
|
|
185
|
+
export function renderWorkspaceDiff(diff, workspace) {
|
|
186
|
+
const lines = [
|
|
187
|
+
`# GTM workspace "${workspace.name}" (${workspace.workspaceId}) vs live version ${diff.liveVersionId}`,
|
|
188
|
+
`${diff.counts.added} added, ${diff.counts.updated} updated, ${diff.counts.deleted} deleted${diff.conflicts ? `, ${diff.conflicts} MERGE CONFLICT(S)` : ''}`,
|
|
189
|
+
];
|
|
190
|
+
if (!diff.changes.length)
|
|
191
|
+
lines.push('', 'No changes. Nothing to publish.');
|
|
192
|
+
for (const c of diff.changes) {
|
|
193
|
+
lines.push(`- ${c.changeStatus.toUpperCase()} ${c.kind} "${c.name}"${c.type ? ` (${c.type})` : ''}${c.fields.length ? `: ${c.fields.join(', ')}` : ''}`);
|
|
194
|
+
}
|
|
195
|
+
lines.push('', `review token: ${reviewToken(diff)}`);
|
|
196
|
+
return lines.join('\n');
|
|
197
|
+
}
|
|
198
|
+
/** Shape a live version into a compact, diffable record. Pure. */
|
|
199
|
+
export function shapeSnapshot(v, ids) {
|
|
200
|
+
const triggerName = new Map((v.trigger ?? []).map((t) => [t.triggerId, t.name]));
|
|
201
|
+
const tags = (v.tag ?? []).map((t) => ({
|
|
202
|
+
id: t.tagId, name: t.name, type: t.type ?? '', paused: !!t.paused,
|
|
203
|
+
firing: (t.firingTriggerId ?? []).map((id) => triggerName.get(id) ?? id),
|
|
204
|
+
blocking: (t.blockingTriggerId ?? []).map((id) => triggerName.get(id) ?? id),
|
|
205
|
+
}));
|
|
206
|
+
return {
|
|
207
|
+
at: new Date().toISOString(),
|
|
208
|
+
accountId: ids.accountId, containerId: ids.containerId, publicId: ids.publicId ?? null,
|
|
209
|
+
versionId: v.containerVersionId, versionName: v.name ?? null, fingerprint: v.fingerprint ?? null,
|
|
210
|
+
counts: { tags: tags.length, triggers: v.trigger?.length ?? 0, variables: v.variable?.length ?? 0, builtIns: v.builtInVariable?.length ?? 0, paused: tags.filter((t) => t.paused).length },
|
|
211
|
+
tags,
|
|
212
|
+
triggers: (v.trigger ?? []).map((t) => ({ id: t.triggerId, name: t.name, type: t.type ?? '' })),
|
|
213
|
+
variables: (v.variable ?? []).map((x) => ({ id: x.variableId, name: x.name, type: x.type ?? '' })),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/** What changed between two snapshots. Pure. */
|
|
217
|
+
export function diffSnapshots(before, after) {
|
|
218
|
+
const byId = (xs) => new Map(xs.map((x) => [x.id, x]));
|
|
219
|
+
const b = byId(before.tags), a = byId(after.tags);
|
|
220
|
+
const changed = [];
|
|
221
|
+
for (const [id, t] of a) {
|
|
222
|
+
const p = b.get(id);
|
|
223
|
+
if (!p)
|
|
224
|
+
continue;
|
|
225
|
+
const fields = [];
|
|
226
|
+
if (p.name !== t.name)
|
|
227
|
+
fields.push('name');
|
|
228
|
+
if (p.type !== t.type)
|
|
229
|
+
fields.push('type');
|
|
230
|
+
if (p.paused !== t.paused)
|
|
231
|
+
fields.push('paused');
|
|
232
|
+
if (JSON.stringify(p.firing) !== JSON.stringify(t.firing))
|
|
233
|
+
fields.push('firing');
|
|
234
|
+
if (JSON.stringify(p.blocking) !== JSON.stringify(t.blocking))
|
|
235
|
+
fields.push('blocking');
|
|
236
|
+
if (fields.length)
|
|
237
|
+
changed.push({ name: t.name, fields });
|
|
238
|
+
}
|
|
239
|
+
const names = (xs, other) => xs.filter((x) => !other.has(x.id)).map((x) => x.name);
|
|
240
|
+
const bt = byId(before.triggers), at = byId(after.triggers), bv = byId(before.variables), av = byId(after.variables);
|
|
241
|
+
return {
|
|
242
|
+
versionChanged: before.versionId !== after.versionId,
|
|
243
|
+
before: { versionId: before.versionId, versionName: before.versionName },
|
|
244
|
+
after: { versionId: after.versionId, versionName: after.versionName },
|
|
245
|
+
tags: { added: names(after.tags, b), removed: names(before.tags, a), changed },
|
|
246
|
+
triggers: { added: names(after.triggers, bt), removed: names(before.triggers, at) },
|
|
247
|
+
variables: { added: names(after.variables, bv), removed: names(before.variables, av) },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/** Human-sized rendering of a snapshot. */
|
|
251
|
+
export function renderSnapshot(s) {
|
|
252
|
+
const lines = [
|
|
253
|
+
`# GTM ${s.publicId ?? s.containerId}: version ${s.versionId}${s.versionName ? ` "${s.versionName}"` : ''}`,
|
|
254
|
+
`${s.counts.tags} tags (${s.counts.paused} paused), ${s.counts.triggers} triggers, ${s.counts.variables} variables, ${s.counts.builtIns} built-ins`,
|
|
255
|
+
'',
|
|
256
|
+
'## Tags',
|
|
257
|
+
];
|
|
258
|
+
for (const t of s.tags)
|
|
259
|
+
lines.push(`- ${t.paused ? '[PAUSED] ' : ''}${t.name} (${t.type}) fires on: ${t.firing.join(', ') || 'nothing'}${t.blocking.length ? `; blocked by: ${t.blocking.join(', ')}` : ''}`);
|
|
260
|
+
return lines.join('\n');
|
|
261
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { googleJson, setTokenProvider, tokenProvider, envTokenProvider, GoogleApiError } from "./auth.js";
|
|
2
|
+
export { login, logout, makeOauthProvider, loadClientConfig, pkcePair, refresh, buildAuthUrl, DEFAULT_SCOPES, SCOPES_FOR, CLIENT_FILE } from "./oauth.js";
|
|
3
|
+
export { defaultTokenStore, dpapiStore, aesStore, SECRETS_DIR } from "./token-store.js";
|
|
4
|
+
export { discover, fetchInventory, matchInventory, applyMatches, hostOf } from "./discover.js";
|
|
5
|
+
export * as gtm from "./gtm.js";
|
|
6
|
+
export * as gsc from "./gsc.js";
|
|
7
|
+
export * as ga4 from "./ga4.js";
|
|
8
|
+
export * as pagespeed from "./pagespeed.js";
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable Google OAuth for the company operations account.
|
|
3
|
+
*
|
|
4
|
+
* One consent, once, on the operator's machine:
|
|
5
|
+
* login() -> browser consent with PKCE -> refresh token -> encrypted store
|
|
6
|
+
* After that, every process refreshes access tokens silently:
|
|
7
|
+
* oauthTokenProvider.accessToken()
|
|
8
|
+
*
|
|
9
|
+
* The client configuration is the "Desktop app" JSON downloaded from Google
|
|
10
|
+
* Cloud Console, placed at secrets/oauth-client.json. It is not a secret in
|
|
11
|
+
* the strict sense for desktop clients, but it is gitignored anyway.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { defaultTokenStore, SECRETS_DIR } from "./token-store.js";
|
|
19
|
+
export const CLIENT_FILE = path.join(SECRETS_DIR, 'oauth-client.json');
|
|
20
|
+
/** Least privilege for v0.3 reads plus the one auto write (sitemap submit). */
|
|
21
|
+
export const DEFAULT_SCOPES = [
|
|
22
|
+
'openid',
|
|
23
|
+
'https://www.googleapis.com/auth/userinfo.email',
|
|
24
|
+
'https://www.googleapis.com/auth/webmasters', // Search Console read + sitemap submit
|
|
25
|
+
'https://www.googleapis.com/auth/analytics.readonly', // GA4 Data + Admin read
|
|
26
|
+
'https://www.googleapis.com/auth/tagmanager.readonly', // GTM read
|
|
27
|
+
];
|
|
28
|
+
/** Scopes needed later, by capability. login({ scopes }) can add them when a write is enabled. */
|
|
29
|
+
export const SCOPES_FOR = {
|
|
30
|
+
ga4_write: 'https://www.googleapis.com/auth/analytics.edit',
|
|
31
|
+
gtm_edit: 'https://www.googleapis.com/auth/tagmanager.edit.containers',
|
|
32
|
+
gtm_publish: 'https://www.googleapis.com/auth/tagmanager.publish',
|
|
33
|
+
};
|
|
34
|
+
export function loadClientConfig(file = CLIENT_FILE) {
|
|
35
|
+
if (!existsSync(file)) {
|
|
36
|
+
throw new Error(`OAuth client file not found: ${file}\n` +
|
|
37
|
+
'Create a "Desktop app" OAuth client in Google Cloud Console, download its JSON, and save it there.');
|
|
38
|
+
}
|
|
39
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
40
|
+
const c = raw.installed ?? raw.web;
|
|
41
|
+
if (!c?.client_id || !c.token_uri)
|
|
42
|
+
throw new Error(`${file}: not a Google OAuth client JSON (expected "installed" with client_id and token_uri).`);
|
|
43
|
+
return {
|
|
44
|
+
client_id: c.client_id, client_secret: c.client_secret ?? '',
|
|
45
|
+
auth_uri: c.auth_uri ?? 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
46
|
+
token_uri: c.token_uri,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// ---------------------------------------------------------------- PKCE
|
|
50
|
+
const b64url = (b) => b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
51
|
+
export function pkcePair() {
|
|
52
|
+
const verifier = b64url(randomBytes(32));
|
|
53
|
+
return { verifier, challenge: b64url(createHash('sha256').update(verifier).digest()) };
|
|
54
|
+
}
|
|
55
|
+
async function tokenRequest(cfg, params, fetchImpl = fetch) {
|
|
56
|
+
const res = await fetchImpl(cfg.token_uri, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
59
|
+
body: new URLSearchParams({ client_id: cfg.client_id, client_secret: cfg.client_secret, ...params }).toString(),
|
|
60
|
+
});
|
|
61
|
+
const body = await res.text();
|
|
62
|
+
if (!res.ok)
|
|
63
|
+
throw new Error(`Google token endpoint ${res.status}: ${body.slice(0, 400)}`);
|
|
64
|
+
return JSON.parse(body);
|
|
65
|
+
}
|
|
66
|
+
export async function refresh(cfg, refreshToken, fetchImpl = fetch) {
|
|
67
|
+
const t = await tokenRequest(cfg, { grant_type: 'refresh_token', refresh_token: refreshToken }, fetchImpl);
|
|
68
|
+
return { accessToken: t.access_token, expiresAt: Date.now() + Math.max(0, (t.expires_in - 60)) * 1000 };
|
|
69
|
+
}
|
|
70
|
+
// ---------------------------------------------------------------- consent flow
|
|
71
|
+
/**
|
|
72
|
+
* Open a URL in the default browser.
|
|
73
|
+
* On Windows we must not go through cmd.exe: it treats "&" as a command
|
|
74
|
+
* separator and truncates the query string. rundll32 passes the URL intact.
|
|
75
|
+
*/
|
|
76
|
+
function openBrowser(url) {
|
|
77
|
+
const [cmd, args] = process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] :
|
|
78
|
+
process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
|
|
79
|
+
try {
|
|
80
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
81
|
+
}
|
|
82
|
+
catch { /* user can paste the URL */ }
|
|
83
|
+
}
|
|
84
|
+
/** Build the consent URL. Pure, so it can be tested. */
|
|
85
|
+
export function buildAuthUrl(cfg, p) {
|
|
86
|
+
const u = new URL(cfg.auth_uri);
|
|
87
|
+
const params = {
|
|
88
|
+
client_id: cfg.client_id, redirect_uri: p.redirectUri, response_type: 'code', scope: p.scopes.join(' '),
|
|
89
|
+
code_challenge: p.challenge, code_challenge_method: 'S256', state: p.state, access_type: 'offline', prompt: 'consent',
|
|
90
|
+
};
|
|
91
|
+
for (const [k, v] of Object.entries(params))
|
|
92
|
+
u.searchParams.set(k, v);
|
|
93
|
+
return u.toString();
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Start a loopback server for the OAuth redirect. Resolves once listening
|
|
97
|
+
* with the redirect URI and a promise for the authorisation code.
|
|
98
|
+
*/
|
|
99
|
+
function startLoopback(expectedState) {
|
|
100
|
+
return new Promise((resolveStart, rejectStart) => {
|
|
101
|
+
let resolveCode;
|
|
102
|
+
let rejectCode;
|
|
103
|
+
const code = new Promise((res, rej) => { resolveCode = res; rejectCode = rej; });
|
|
104
|
+
const server = http.createServer((req, res) => {
|
|
105
|
+
const u = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
106
|
+
const got = u.searchParams.get('code'), state = u.searchParams.get('state'), err = u.searchParams.get('error');
|
|
107
|
+
const page = (msg) => {
|
|
108
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
109
|
+
res.end(`<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;padding:2rem"><h2>Google Web Operations</h2><p>${msg}</p></body>`);
|
|
110
|
+
};
|
|
111
|
+
if (err) {
|
|
112
|
+
page(`Authorisation failed: ${err}. You can close this tab.`);
|
|
113
|
+
server.close();
|
|
114
|
+
rejectCode(new Error(err));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (!got || state !== expectedState) {
|
|
118
|
+
page('Unexpected request. You can close this tab.');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
page('Authorised. You can close this tab and return to the terminal.');
|
|
122
|
+
server.close();
|
|
123
|
+
resolveCode(got);
|
|
124
|
+
});
|
|
125
|
+
server.on('error', rejectStart);
|
|
126
|
+
server.listen(0, '127.0.0.1', () => {
|
|
127
|
+
const a = server.address();
|
|
128
|
+
const port = typeof a === 'object' && a ? a.port : 0;
|
|
129
|
+
resolveStart({ redirectUri: `http://127.0.0.1:${port}`, code });
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/** Run the consent flow once and persist the refresh token. Returns non-secret status. */
|
|
134
|
+
export async function login(opts = {}) {
|
|
135
|
+
const cfg = loadClientConfig(opts.clientFile);
|
|
136
|
+
const store = opts.store ?? defaultTokenStore();
|
|
137
|
+
const log = opts.log ?? ((s) => console.error(s));
|
|
138
|
+
const scopes = [...new Set([...DEFAULT_SCOPES, ...(opts.scopes ?? [])])];
|
|
139
|
+
const { verifier, challenge } = pkcePair();
|
|
140
|
+
const state = b64url(randomBytes(16));
|
|
141
|
+
const { redirectUri, code: codePromise } = await startLoopback(state);
|
|
142
|
+
const authUrl = buildAuthUrl(cfg, { redirectUri, scopes, challenge, state });
|
|
143
|
+
if (opts.open === false) {
|
|
144
|
+
log('Open this URL in a browser signed in as the operations account:\n' + authUrl);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
log('Opening the browser for Google consent. If it does not open, visit:\n' + authUrl);
|
|
148
|
+
openBrowser(authUrl);
|
|
149
|
+
}
|
|
150
|
+
const code = await codePromise;
|
|
151
|
+
const t = await tokenRequest(cfg, { grant_type: 'authorization_code', code, redirect_uri: redirectUri, code_verifier: verifier });
|
|
152
|
+
if (!t.refresh_token)
|
|
153
|
+
throw new Error('Google did not return a refresh token. Revoke the app at myaccount.google.com/permissions and log in again.');
|
|
154
|
+
let email = null;
|
|
155
|
+
try {
|
|
156
|
+
const ui = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { headers: { authorization: `Bearer ${t.access_token}` } });
|
|
157
|
+
if (ui.ok)
|
|
158
|
+
email = (await ui.json()).email ?? null;
|
|
159
|
+
}
|
|
160
|
+
catch { /* email is cosmetic */ }
|
|
161
|
+
const record = {
|
|
162
|
+
refresh_token: t.refresh_token, client_id: cfg.client_id,
|
|
163
|
+
scopes: (t.scope ?? scopes.join(' ')).split(' ').filter(Boolean), email, obtained_at: new Date().toISOString(),
|
|
164
|
+
};
|
|
165
|
+
store.save(record);
|
|
166
|
+
return { email, scopes: record.scopes, store: store.describe() };
|
|
167
|
+
}
|
|
168
|
+
// ---------------------------------------------------------------- provider
|
|
169
|
+
/** Token provider backed by the encrypted store. Refreshes silently; caches in memory. */
|
|
170
|
+
export function makeOauthProvider(store = defaultTokenStore(), clientFile, fetchImpl = fetch) {
|
|
171
|
+
let cached = null;
|
|
172
|
+
let record;
|
|
173
|
+
const load = () => (record === undefined ? (record = store.load()) : record);
|
|
174
|
+
return {
|
|
175
|
+
async accessToken() {
|
|
176
|
+
const r = load();
|
|
177
|
+
if (!r)
|
|
178
|
+
throw new Error('No Google authorisation stored. Run: npm run cli -- auth login');
|
|
179
|
+
if (cached && Date.now() < cached.expiresAt)
|
|
180
|
+
return cached.accessToken;
|
|
181
|
+
cached = await refresh(loadClientConfig(clientFile), r.refresh_token, fetchImpl);
|
|
182
|
+
return cached.accessToken;
|
|
183
|
+
},
|
|
184
|
+
describe() {
|
|
185
|
+
const r = load();
|
|
186
|
+
return r ? `OAuth refresh token for ${r.email ?? 'unknown account'} (${store.describe()})` : `no stored authorisation (${store.describe()})`;
|
|
187
|
+
},
|
|
188
|
+
status() {
|
|
189
|
+
const r = load();
|
|
190
|
+
return {
|
|
191
|
+
authorised: !!r, email: r?.email ?? null, scopes: r?.scopes ?? [], obtainedAt: r?.obtained_at ?? null,
|
|
192
|
+
store: store.describe(), clientFile: existsSync(clientFile ?? CLIENT_FILE) ? (clientFile ?? CLIENT_FILE) : null,
|
|
193
|
+
accessTokenCached: !!cached && Date.now() < cached.expiresAt,
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export function logout(store = defaultTokenStore()) { store.clear(); }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PageSpeed Insights v5: Lighthouse lab data plus CrUX field data.
|
|
3
|
+
*
|
|
4
|
+
* Quota is attributed to whoever the request identifies as. Keyless calls
|
|
5
|
+
* share Google's default project, whose daily quota is routinely exhausted.
|
|
6
|
+
* So we identify as our own project: an API key if configured, otherwise the
|
|
7
|
+
* stored OAuth token. Either needs "PageSpeed Insights API" enabled in the
|
|
8
|
+
* Google Cloud project; Google's error says so if it is not.
|
|
9
|
+
*/
|
|
10
|
+
import { tokenProvider } from "./auth.js";
|
|
11
|
+
export async function runPageSpeed(url, strategy = 'mobile') {
|
|
12
|
+
const api = new URL('https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
|
|
13
|
+
api.searchParams.set('url', url);
|
|
14
|
+
api.searchParams.set('strategy', strategy);
|
|
15
|
+
for (const c of ['performance', 'seo', 'accessibility', 'best-practices'])
|
|
16
|
+
api.searchParams.append('category', c);
|
|
17
|
+
const headers = {};
|
|
18
|
+
const key = process.env['GOOGLE_PAGESPEED_API_KEY']?.trim();
|
|
19
|
+
if (key)
|
|
20
|
+
api.searchParams.set('key', key);
|
|
21
|
+
else {
|
|
22
|
+
try {
|
|
23
|
+
headers['authorization'] = `Bearer ${await tokenProvider().accessToken()}`;
|
|
24
|
+
}
|
|
25
|
+
catch { /* no credentials: fall back to the shared anonymous quota */ }
|
|
26
|
+
}
|
|
27
|
+
const res = await fetch(api, { headers });
|
|
28
|
+
if (!res.ok)
|
|
29
|
+
throw new Error(`PageSpeed ${res.status}: ${(await res.text()).slice(0, 600)}`);
|
|
30
|
+
return res.json();
|
|
31
|
+
}
|
|
32
|
+
/** The handful of numbers an operator actually reads. */
|
|
33
|
+
export async function summarisePageSpeed(url, strategy = 'mobile') {
|
|
34
|
+
const raw = (await runPageSpeed(url, strategy));
|
|
35
|
+
const lh = raw['lighthouseResult'];
|
|
36
|
+
const cats = (lh?.['categories'] ?? {});
|
|
37
|
+
const audits = (lh?.['audits'] ?? {});
|
|
38
|
+
const field = raw['loadingExperience']?.['metrics'];
|
|
39
|
+
const score = (k) => (cats[k]?.score != null ? Math.round(cats[k]?.score * 100) : null);
|
|
40
|
+
const lab = (k) => audits[k]?.displayValue ?? null;
|
|
41
|
+
const cwv = (k) => (field?.[k] ? { p75: field[k]?.percentile ?? null, category: field[k]?.category ?? null } : null);
|
|
42
|
+
return {
|
|
43
|
+
url, strategy,
|
|
44
|
+
scores: { performance: score('performance'), seo: score('seo'), accessibility: score('accessibility'), bestPractices: score('best-practices') },
|
|
45
|
+
lab: { lcp: lab('largest-contentful-paint'), cls: lab('cumulative-layout-shift'), tbt: lab('total-blocking-time'), fcp: lab('first-contentful-paint'), ttfb: lab('server-response-time') },
|
|
46
|
+
field: field ? { lcp: cwv('LARGEST_CONTENTFUL_PAINT_MS'), cls: cwv('CUMULATIVE_LAYOUT_SHIFT_SCORE'), inp: cwv('INTERACTION_TO_NEXT_PAINT') } : null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted-at-rest storage for the Google refresh token.
|
|
3
|
+
*
|
|
4
|
+
* Windows: the record is protected with DPAPI (CurrentUser scope), so only
|
|
5
|
+
* the same Windows user on the same machine can read it. No key
|
|
6
|
+
* file exists anywhere.
|
|
7
|
+
* Other: AES-256-GCM with a random key kept in secrets/.token-key (0600).
|
|
8
|
+
*
|
|
9
|
+
* Only the refresh token is stored. Access tokens live in process memory.
|
|
10
|
+
* Everything under secrets/ is gitignored.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { SECRETS_DIR } from "../../../core/src/paths.js";
|
|
17
|
+
export { SECRETS_DIR };
|
|
18
|
+
function ensureDir() { mkdirSync(SECRETS_DIR, { recursive: true }); }
|
|
19
|
+
// ---------------------------------------------------------------- Windows DPAPI
|
|
20
|
+
const DPAPI_FILE = path.join(SECRETS_DIR, 'google-token.dpapi');
|
|
21
|
+
/** Round-trip bytes through System.Security.Cryptography.ProtectedData via PowerShell. */
|
|
22
|
+
function dpapi(mode, b64) {
|
|
23
|
+
const script = 'Add-Type -AssemblyName System.Security; ' +
|
|
24
|
+
'$in = [Convert]::FromBase64String(($input | Out-String).Trim()); ' +
|
|
25
|
+
`$out = [System.Security.Cryptography.ProtectedData]::${mode}($in, $null, 'CurrentUser'); ` +
|
|
26
|
+
'[Convert]::ToBase64String($out)';
|
|
27
|
+
return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
28
|
+
input: b64, encoding: 'utf8', windowsHide: true,
|
|
29
|
+
}).trim();
|
|
30
|
+
}
|
|
31
|
+
export const dpapiStore = {
|
|
32
|
+
save(record) {
|
|
33
|
+
ensureDir();
|
|
34
|
+
const plain = Buffer.from(JSON.stringify(record), 'utf8').toString('base64');
|
|
35
|
+
writeFileSync(DPAPI_FILE, dpapi('Protect', plain), 'utf8');
|
|
36
|
+
},
|
|
37
|
+
load() {
|
|
38
|
+
if (!existsSync(DPAPI_FILE))
|
|
39
|
+
return null;
|
|
40
|
+
const plain = dpapi('Unprotect', readFileSync(DPAPI_FILE, 'utf8').trim());
|
|
41
|
+
return JSON.parse(Buffer.from(plain, 'base64').toString('utf8'));
|
|
42
|
+
},
|
|
43
|
+
clear() { rmSync(DPAPI_FILE, { force: true }); },
|
|
44
|
+
describe() { return `DPAPI (CurrentUser) at ${DPAPI_FILE}${existsSync(DPAPI_FILE) ? '' : ' (absent)'}`; },
|
|
45
|
+
};
|
|
46
|
+
// ---------------------------------------------------------------- portable AES-GCM
|
|
47
|
+
const AES_FILE = path.join(SECRETS_DIR, 'google-token.enc');
|
|
48
|
+
const KEY_FILE = path.join(SECRETS_DIR, '.token-key');
|
|
49
|
+
function aesKey() {
|
|
50
|
+
ensureDir();
|
|
51
|
+
if (!existsSync(KEY_FILE)) {
|
|
52
|
+
writeFileSync(KEY_FILE, randomBytes(32));
|
|
53
|
+
try {
|
|
54
|
+
chmodSync(KEY_FILE, 0o600);
|
|
55
|
+
}
|
|
56
|
+
catch { /* not supported on this fs */ }
|
|
57
|
+
}
|
|
58
|
+
return readFileSync(KEY_FILE);
|
|
59
|
+
}
|
|
60
|
+
export const aesStore = {
|
|
61
|
+
save(record) {
|
|
62
|
+
const key = aesKey();
|
|
63
|
+
const iv = randomBytes(12);
|
|
64
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
65
|
+
const ct = Buffer.concat([cipher.update(JSON.stringify(record), 'utf8'), cipher.final()]);
|
|
66
|
+
writeFileSync(AES_FILE, Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64'), 'utf8');
|
|
67
|
+
try {
|
|
68
|
+
chmodSync(AES_FILE, 0o600);
|
|
69
|
+
}
|
|
70
|
+
catch { /* ignore */ }
|
|
71
|
+
},
|
|
72
|
+
load() {
|
|
73
|
+
if (!existsSync(AES_FILE))
|
|
74
|
+
return null;
|
|
75
|
+
const buf = Buffer.from(readFileSync(AES_FILE, 'utf8'), 'base64');
|
|
76
|
+
const iv = buf.subarray(0, 12), tag = buf.subarray(12, 28), ct = buf.subarray(28);
|
|
77
|
+
const decipher = createDecipheriv('aes-256-gcm', aesKey(), iv);
|
|
78
|
+
decipher.setAuthTag(tag);
|
|
79
|
+
return JSON.parse(Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'));
|
|
80
|
+
},
|
|
81
|
+
clear() { rmSync(AES_FILE, { force: true }); },
|
|
82
|
+
describe() { return `AES-256-GCM at ${AES_FILE}${existsSync(AES_FILE) ? '' : ' (absent)'}`; },
|
|
83
|
+
};
|
|
84
|
+
/** The store for this platform. Override with GWO_TOKEN_STORE=aes to force the portable one. */
|
|
85
|
+
export function defaultTokenStore() {
|
|
86
|
+
if (process.env['GWO_TOKEN_STORE'] === 'aes')
|
|
87
|
+
return aesStore;
|
|
88
|
+
return process.platform === 'win32' ? dpapiStore : aesStore;
|
|
89
|
+
}
|