backend-manager 5.2.13 → 5.2.15
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/CHANGELOG.md +11 -1
- package/docs/ai-library.md +16 -2
- package/docs/marketing-campaigns.md +1 -1
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +19 -4
- package/src/cli/commands/setup-tests/env-runtime-config-deprecated.js +1 -1
- package/src/manager/events/auth/on-create.js +12 -0
- package/src/manager/functions/_legacy/admin/create-post.js +2 -2
- package/src/manager/functions/core/actions/api/admin/create-post.js +1 -1
- package/src/manager/functions/core/actions/api/admin/edit-post.js +2 -2
- package/src/manager/functions/core/actions/api/admin/write-repo-content.js +2 -2
- package/src/manager/functions/core/actions/api/general/fetch-post.js +2 -2
- package/src/manager/libraries/ai/providers/claude-code.js +115 -111
- package/src/manager/libraries/email/generators/lib/image-host.js +3 -3
- package/src/manager/routes/admin/post/post.js +2 -2
- package/src/manager/routes/admin/post/put.js +2 -2
- package/src/manager/routes/admin/repo/content/post.js +2 -2
- package/src/manager/routes/content/post/get.js +2 -2
- package/src/manager/routes/user/signup/post.js +30 -16
- package/templates/_.env +3 -3
- package/test/functions/admin/edit-post.js +9 -9
- package/test/functions/admin/write-repo-content.js +3 -3
- package/test/routes/admin/create-post.js +6 -6
- package/test/routes/admin/post.js +9 -9
- package/test/routes/admin/repo-content.js +3 -3
- package/test/routes/user/signup.js +8 -9
package/CHANGELOG.md
CHANGED
|
@@ -14,7 +14,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
-
# [5.2.
|
|
17
|
+
# [5.2.15] - 2026-05-28
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Standardized the GitHub token env var `GITHUB_TOKEN` → `GH_TOKEN`** across the entire repo, to match the convention used in all other ITW repos. Updated every GitHub-backed route and action (`admin/post`, `content/post`, `admin/repo/content`, `general/fetch-post`, `admin/write-repo-content`, `admin/edit-post`, `admin/create-post`, legacy `create-post`), the email image-host library (`libraries/email/generators/lib/image-host.js`), the CLI deprecated-env notice, the `templates/_.env` scaffold, the `docs/marketing-campaigns.md` reference, and all related test files. This is a hard rename with no fallback — any environment (CI, prod, local `.env`) that still sets `GITHUB_TOKEN` must switch to `GH_TOKEN` for the GitHub-backed routes to work.
|
|
22
|
+
|
|
23
|
+
# [5.2.14] - 2026-05-28
|
|
18
24
|
|
|
19
25
|
### Removed
|
|
20
26
|
|
|
@@ -27,6 +33,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
27
33
|
|
|
28
34
|
### Changed
|
|
29
35
|
|
|
36
|
+
- **Signup timestamps stamped from Firebase Auth `creationTime`.** Both write paths — the `onCreate` auth event (`src/manager/events/auth/on-create.js`) and the `user/signup` fallback route (`src/manager/routes/user/signup/post.js`) — now set `metadata.created` and `consent.{legal,marketing}.grantedAt`/`revokedAt` from `user.metadata.creationTime` instead of "now"/request-time. The OMEGA user migration treats Auth's `creationTime` as the SSOT and reconciles every doc against it; the prior few-seconds drift meant every new signup got re-fixed on the next migration run. Stamping from `creationTime` makes new docs match the migration's expected value exactly, ending the recurring churn.
|
|
37
|
+
- **`user/signup`: `flags.signupProcessed` is the sole idempotency gate.** Removed the 5-minute account-age reject (`MAX_ACCOUNT_AGE_MS`). A genuinely-unprocessed account can now complete signup whenever it retries — fixes the failure where a slow/missing `onCreate` plus a retry after 5 minutes caused a legitimate signup to be rejected. The frontend (UJM) gate is being moved to the same doc flag in a parallel change.
|
|
38
|
+
- **CLI: port-conflict prompt auto-confirms after 5s.** The "Kill these processes to free the ports?" prompt in `src/cli/commands/base-command.js` now auto-confirms `Y` after 5 seconds of no input (via `AbortSignal.timeout` → `AbortPromptError` → default `true`), so unattended test/dev loops (`emulator`, `serve`, `test`) no longer hang waiting for input. Manual `y`/`n` and Ctrl+C still work.
|
|
39
|
+
- **`AI` `claude-code` provider rewritten for serverside use** (`src/manager/libraries/ai/providers/claude-code.js`). Was a local-only wrapper around `@anthropic-ai/claude-agent-sdk` that spawned the `claude` binary (`forceLoginMethod: 'claudeai'`, keychain OAuth) — would not run in Cloud Functions. Now calls the Claude Messages API over plain HTTPS via `@anthropic-ai/sdk` using the OAuth token as `Authorization: Bearer` + `anthropic-beta: oauth-2025-04-20`, so it bills the Claude Pro/Max subscription (not API credits) and runs anywhere Node runs. Token resolution: `options.apiKey` → `config.claude_code.oauth_token` → `process.env.CLAUDE_CODE_OAUTH_TOKEN`. Renewal is a manual yearly `claude setup-token` (no auto-refresh). Verified live (text + JSON-schema paths). See `docs/ai-library.md`.
|
|
30
40
|
- **Dependency bumps**: `@anthropic-ai/claude-agent-sdk` ^0.3.152 → ^0.3.153, `stripe` ^22.1.1 → ^22.2.0.
|
|
31
41
|
- **`templates/_.env`**: consolidate OpenAI/Anthropic keys under an "AI" section and add `CLAUDE_CODE_OAUTH_TOKEN`.
|
|
32
42
|
- **Marketing webhook tests** (`test/routes/marketing/webhook.js`): renamed `*-duplicate-event-skipped` → `*-reprocessed-idempotently` (assert re-delivery reprocesses and the user stays revoked); `sendgrid-event-without-eventId-*` now asserts the event is processed; beehiiv idempotency variant skips when no publication is configured. Updated `docs/consent.md` and `docs/testing.md` to describe the no-ledger design.
|
package/docs/ai-library.md
CHANGED
|
@@ -6,18 +6,32 @@
|
|
|
6
6
|
|---|---|---|
|
|
7
7
|
| `openai` | `gpt-5-mini` | Better at structured JSON via JSON schema |
|
|
8
8
|
| `anthropic` | `claude-sonnet-4-6` | Better at SVG illustrations and creative output |
|
|
9
|
+
| `claude-code` | `claude-opus-4-7` | Same Claude models as `anthropic`, but bills a Claude Pro/Max **subscription** instead of API credits |
|
|
9
10
|
|
|
10
11
|
Return shape (same for all providers): `{ content, output, tokens, raw }`.
|
|
11
12
|
|
|
12
|
-
`options.response: 'json'` triggers JSON parsing —
|
|
13
|
+
`options.response: 'json'` triggers JSON parsing — all providers strip fences and parse with JSON5 for robustness. `options.schema` enforces structure on OpenAI (real JSON schema) and is injected into the system prompt on Anthropic / claude-code.
|
|
13
14
|
|
|
14
15
|
API keys: `BACKEND_MANAGER_OPENAI_API_KEY`, `BACKEND_MANAGER_ANTHROPIC_API_KEY` (process.env or config).
|
|
15
16
|
|
|
17
|
+
## `claude-code` provider — subscription billing
|
|
18
|
+
|
|
19
|
+
The `claude-code` provider hits the same Claude Messages API as `anthropic`, but authenticates with a **Claude Code OAuth token** (`Authorization: Bearer ...` + `anthropic-beta: oauth-2025-04-20`) so usage bills the Claude Pro/Max subscription tied to the token rather than API credits.
|
|
20
|
+
|
|
21
|
+
It is **pure HTTPS** — no `claude` binary, no subprocess — so it runs in Cloud Functions / CI / anywhere Node runs.
|
|
22
|
+
|
|
23
|
+
Token resolution (first match wins): `options.apiKey` / constructor key → `config.claude_code.oauth_token` → `process.env.CLAUDE_CODE_OAUTH_TOKEN`.
|
|
24
|
+
|
|
25
|
+
Mint the token with `claude setup-token` (valid ~1 year). When it expires, requests 401 — re-mint and update the `CLAUDE_CODE_OAUTH_TOKEN` secret. There is no automatic refresh; renewal is a manual yearly step.
|
|
26
|
+
|
|
27
|
+
> **Caveats:** the Bearer/beta subscription path is undocumented and may change. Usage is subject to the subscription's rate limits (not API-tier limits). For high-volume production traffic, prefer `anthropic` + `BACKEND_MANAGER_ANTHROPIC_API_KEY`.
|
|
28
|
+
|
|
16
29
|
The legacy `src/manager/libraries/openai.js` is a thin compatibility shim that re-exports the OpenAI provider class — existing callers using `new OpenAI(assistant, key)` still work unchanged.
|
|
17
30
|
|
|
18
31
|
| File | Purpose |
|
|
19
32
|
|---|---|
|
|
20
33
|
| `src/manager/libraries/ai/index.js` | Unified `AI` class (dispatches by provider) |
|
|
21
34
|
| `src/manager/libraries/ai/providers/openai.js` | OpenAI provider (original `openai.js` content) |
|
|
22
|
-
| `src/manager/libraries/ai/providers/anthropic.js` | Anthropic provider (Claude Messages API) |
|
|
35
|
+
| `src/manager/libraries/ai/providers/anthropic.js` | Anthropic provider (Claude Messages API, x-api-key, API credits) |
|
|
36
|
+
| `src/manager/libraries/ai/providers/claude-code.js` | claude-code provider (Claude Messages API, OAuth Bearer, subscription billing) |
|
|
23
37
|
| `src/manager/libraries/openai.js` | Back-compat shim → providers/openai.js |
|
|
@@ -208,7 +208,7 @@ Templates add their own fields on top (e.g. classic adds `intro` + `sections`; f
|
|
|
208
208
|
|
|
209
209
|
This means the newsletter is never "stuck" — even with Beehiiv disabled or failing, you get an actionable email pointing to ready-to-paste assets. The alert is best-effort; failure to send is logged but does not block the Firestore campaign-doc write.
|
|
210
210
|
|
|
211
|
-
Requires `
|
|
211
|
+
Requires `GH_TOKEN` env var (org-scoped, write access to `newsletter-assets`). Without it, the cron's HTML/image upload calls throw and the run aborts.
|
|
212
212
|
|
|
213
213
|
## Iteration test asset story
|
|
214
214
|
|
package/package.json
CHANGED
|
@@ -130,10 +130,25 @@ class BaseCommand {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
// Auto-confirm (Y) after a few seconds of no input so unattended test/dev loops don't
|
|
134
|
+
// hang. When the timeout fires the prompt is aborted via AbortSignal and we fall back to
|
|
135
|
+
// the default (true). inquirer owns the cursor and can't live-update its own message, so
|
|
136
|
+
// the countdown is shown statically in the prompt.
|
|
137
|
+
const AUTO_CONFIRM_SECONDS = 5;
|
|
138
|
+
let shouldKill;
|
|
139
|
+
try {
|
|
140
|
+
shouldKill = await confirm(
|
|
141
|
+
{ message: `Kill these processes to free the ports? (auto-Y in ${AUTO_CONFIRM_SECONDS}s)`, default: true },
|
|
142
|
+
{ signal: AbortSignal.timeout(AUTO_CONFIRM_SECONDS * 1000) },
|
|
143
|
+
);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// AbortPromptError (timeout reached) → take the default (true). Re-throw anything else.
|
|
146
|
+
if (error?.name !== 'AbortPromptError') {
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
this.log(chalk.gray(' No input — auto-confirming (Y).'));
|
|
150
|
+
shouldKill = true;
|
|
151
|
+
}
|
|
137
152
|
|
|
138
153
|
if (!shouldKill) {
|
|
139
154
|
this.log(chalk.gray('\n Aborting. Free the ports and try again.\n'));
|
|
@@ -36,7 +36,7 @@ class EnvRuntimeConfigDeprecatedTest extends BaseTest {
|
|
|
36
36
|
' The new format uses individual environment variables:\n' +
|
|
37
37
|
' BACKEND_MANAGER_KEY=\n' +
|
|
38
38
|
' BACKEND_MANAGER_NAMESPACE=\n' +
|
|
39
|
-
'
|
|
39
|
+
' GH_TOKEN=\n' +
|
|
40
40
|
' Please update your .env file manually and remove RUNTIME_CONFIG.'
|
|
41
41
|
);
|
|
42
42
|
}
|
|
@@ -72,6 +72,18 @@ module.exports = async ({ Manager, assistant, user, context, libraries }) => {
|
|
|
72
72
|
const meta = Manager.Metadata().set({ tag: 'auth:on-create' });
|
|
73
73
|
userRecord.metadata = { ...userRecord.metadata, ...meta };
|
|
74
74
|
|
|
75
|
+
// Stamp metadata.created from Firebase Auth's creationTime (the canonical account-creation
|
|
76
|
+
// moment) rather than the User schema's "now" — otherwise the doc lands a beat after Auth,
|
|
77
|
+
// and the OMEGA user migration reconciles every new signup against Auth on its next run.
|
|
78
|
+
const creationTime = user.metadata?.creationTime;
|
|
79
|
+
if (creationTime) {
|
|
80
|
+
const createdDate = new Date(creationTime);
|
|
81
|
+
userRecord.metadata.created = {
|
|
82
|
+
timestamp: createdDate.toISOString(),
|
|
83
|
+
timestampUNIX: Math.round(createdDate.getTime() / 1000),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
75
87
|
assistant.log(`onCreate: Creating user doc for ${user.uid}`, userRecord);
|
|
76
88
|
|
|
77
89
|
// Write user doc with retry
|
|
@@ -55,7 +55,7 @@ let Module = {
|
|
|
55
55
|
return new Promise(async function(resolve, reject) {
|
|
56
56
|
let finalPath = poster.removeDirDot(meta.finalPath);
|
|
57
57
|
let tempPath = (meta.tempPath);
|
|
58
|
-
await createFile(self.Manager.config?.github?.user, repoInfo.user, repoInfo.name, process.env.
|
|
58
|
+
await createFile(self.Manager.config?.github?.user, repoInfo.user, repoInfo.name, process.env.GH_TOKEN, finalPath, await poster.readImage(tempPath))
|
|
59
59
|
.catch((e) => {
|
|
60
60
|
// console.log('---CAUGHT 1', e);
|
|
61
61
|
})
|
|
@@ -66,7 +66,7 @@ let Module = {
|
|
|
66
66
|
let finalPost = await poster.create(assistant.request.data);
|
|
67
67
|
|
|
68
68
|
// Save post OR commit
|
|
69
|
-
await createFile(self.Manager.config?.github?.user, repoInfo.user, repoInfo.name, process.env.
|
|
69
|
+
await createFile(self.Manager.config?.github?.user, repoInfo.user, repoInfo.name, process.env.GH_TOKEN, poster.removeDirDot(finalPost.path), finalPost.content)
|
|
70
70
|
.catch((e) => {
|
|
71
71
|
response.status = 400;
|
|
72
72
|
response.error = new Error('Failed to post: ' + e);
|
|
@@ -25,7 +25,7 @@ Module.prototype.main = function () {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
// Check for GitHub configuration
|
|
28
|
-
if (!process.env.
|
|
28
|
+
if (!process.env.GH_TOKEN) {
|
|
29
29
|
return reject(assistant.errorify(`GitHub API key not configured.`, {code: 500}));
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -42,7 +42,7 @@ Module.prototype.main = function () {
|
|
|
42
42
|
|
|
43
43
|
// Setup Octokit
|
|
44
44
|
self.octokit = new Octokit({
|
|
45
|
-
auth: process.env.
|
|
45
|
+
auth: process.env.GH_TOKEN,
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// Check for required values
|
|
@@ -25,7 +25,7 @@ Module.prototype.main = function () {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
// Check for GitHub configuration
|
|
28
|
-
if (!process.env.
|
|
28
|
+
if (!process.env.GH_TOKEN) {
|
|
29
29
|
return reject(assistant.errorify(`GitHub API key not configured.`, {code: 500}));
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -42,7 +42,7 @@ Module.prototype.main = function () {
|
|
|
42
42
|
|
|
43
43
|
// Setup Octokit
|
|
44
44
|
self.octokit = new Octokit({
|
|
45
|
-
auth: process.env.
|
|
45
|
+
auth: process.env.GH_TOKEN,
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// Check for required values
|
|
@@ -14,7 +14,7 @@ Module.prototype.main = function () {
|
|
|
14
14
|
|
|
15
15
|
return new Promise(async function(resolve, reject) {
|
|
16
16
|
// Check for GitHub configuration
|
|
17
|
-
if (!process.env.
|
|
17
|
+
if (!process.env.GH_TOKEN) {
|
|
18
18
|
return reject(assistant.errorify(`GitHub API key not configured.`, {code: 500}));
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -24,7 +24,7 @@ Module.prototype.main = function () {
|
|
|
24
24
|
|
|
25
25
|
// Setup Octokit
|
|
26
26
|
const octokit = new Octokit({
|
|
27
|
-
auth: process.env.
|
|
27
|
+
auth: process.env.GH_TOKEN,
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
// Setup options
|
|
@@ -1,30 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Claude Code provider —
|
|
3
|
-
*
|
|
2
|
+
* Claude Code provider — calls Claude over plain HTTPS using a Claude Code /
|
|
3
|
+
* Claude Pro/Max OAuth token (NOT API credits).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Unlike the Anthropic provider (which authenticates with an x-api-key and bills
|
|
6
|
+
* API credits), this provider sends the OAuth token as `Authorization: Bearer ...`
|
|
7
|
+
* plus the `anthropic-beta: oauth-2025-04-20` header, so usage bills against the
|
|
8
|
+
* Claude subscription tied to the token.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Token resolution (first match wins):
|
|
11
|
+
* 1. explicit key passed to the constructor / options.apiKey
|
|
12
|
+
* 2. Manager config: config.claude_code.oauth_token
|
|
13
|
+
* 3. process.env.CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`)
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* This is pure HTTPS — no `claude` binary, no subprocess — so it runs in Cloud
|
|
16
|
+
* Functions / CI / anywhere Node runs. It is subject to the token's subscription
|
|
17
|
+
* rate limits (not API-tier limits).
|
|
18
|
+
*
|
|
19
|
+
* The OAuth token is minted with `claude setup-token` (valid ~1 year). When it
|
|
20
|
+
* expires (requests 401), re-mint and update the CLAUDE_CODE_OAUTH_TOKEN secret.
|
|
21
|
+
* NOTE: the Bearer/beta subscription path is undocumented and may change.
|
|
22
|
+
*
|
|
23
|
+
* Returns the same { content, output, tokens, raw } shape as the other providers.
|
|
15
24
|
*/
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
// Lazy import — only load the SDK if this provider is actually used
|
|
19
|
-
let _query;
|
|
20
|
-
|
|
21
|
-
function loadQuery() {
|
|
22
|
-
if (!_query) {
|
|
23
|
-
_query = require('@anthropic-ai/claude-agent-sdk').query;
|
|
24
|
-
}
|
|
25
|
+
const _ = require('lodash');
|
|
26
|
+
const JSON5 = require('json5');
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
const DEFAULT_MODEL = 'claude-opus-4-7';
|
|
29
|
+
const OAUTH_BETA = 'oauth-2025-04-20';
|
|
30
|
+
|
|
31
|
+
// Pricing per 1M tokens (USD) — informational only; subscription billing is flat.
|
|
32
|
+
const MODEL_TABLE = {
|
|
33
|
+
'claude-opus-4-7': { input: 15.00, output: 75.00 },
|
|
34
|
+
'claude-opus-4-6': { input: 15.00, output: 75.00 },
|
|
35
|
+
'claude-sonnet-4-6': { input: 3.00, output: 15.00 },
|
|
36
|
+
'claude-haiku-4-5': { input: 1.00, output: 5.00 },
|
|
37
|
+
};
|
|
28
38
|
|
|
29
39
|
function ClaudeCode(assistant, key) {
|
|
30
40
|
const self = this;
|
|
@@ -32,7 +42,9 @@ function ClaudeCode(assistant, key) {
|
|
|
32
42
|
self.assistant = assistant;
|
|
33
43
|
self.Manager = assistant?.Manager;
|
|
34
44
|
self.user = assistant?.user;
|
|
35
|
-
|
|
45
|
+
self.token = key
|
|
46
|
+
|| self.Manager?.config?.claude_code?.oauth_token
|
|
47
|
+
|| process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
36
48
|
|
|
37
49
|
self.tokens = {
|
|
38
50
|
total: { count: 0, price: 0 },
|
|
@@ -47,121 +59,110 @@ ClaudeCode.prototype.request = async function (options) {
|
|
|
47
59
|
const self = this;
|
|
48
60
|
const assistant = self.assistant;
|
|
49
61
|
|
|
50
|
-
options =
|
|
51
|
-
|
|
62
|
+
options = _.merge({}, options);
|
|
63
|
+
options.model = options.model || DEFAULT_MODEL;
|
|
64
|
+
options.maxTokens = options.maxTokens || 2048;
|
|
65
|
+
options.temperature = typeof options.temperature === 'undefined' ? 0.7 : options.temperature;
|
|
52
66
|
|
|
53
|
-
|
|
54
|
-
const { system, prompt } = extractPromptAndSystem(options);
|
|
67
|
+
const token = options.apiKey || self.token;
|
|
55
68
|
|
|
56
|
-
if (!
|
|
57
|
-
throw new Error('claude-code provider requires
|
|
69
|
+
if (!token) {
|
|
70
|
+
throw new Error('claude-code provider requires a Claude OAuth token (set CLAUDE_CODE_OAUTH_TOKEN via `claude setup-token`)');
|
|
58
71
|
}
|
|
59
72
|
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
// Lazy-require the SDK so projects that don't use this provider don't need it.
|
|
74
|
+
// We use the SDK purely as an HTTP client — `authToken` makes it send
|
|
75
|
+
// `Authorization: Bearer <token>` instead of `x-api-key`, and the beta header
|
|
76
|
+
// selects the subscription (OAuth) billing path. No `claude` binary involved.
|
|
77
|
+
const SDK = require('@anthropic-ai/sdk');
|
|
78
|
+
const client = new SDK({
|
|
79
|
+
authToken: token,
|
|
80
|
+
defaultHeaders: { 'anthropic-beta': OAUTH_BETA },
|
|
81
|
+
});
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
const { system, messages } = buildMessages(options);
|
|
84
|
+
|
|
85
|
+
let systemFinal = system;
|
|
86
|
+
|
|
87
|
+
if (options.response === 'json') {
|
|
88
|
+
systemFinal = `${systemFinal || ''}\n\nYou MUST respond with valid JSON only. No prose, no markdown fences, no explanation — just the JSON object.${options.schema ? `\n\nThe JSON must conform to this schema:\n${JSON.stringify(options.schema)}` : ''}`.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const requestBody = {
|
|
92
|
+
model: options.model,
|
|
93
|
+
max_tokens: options.maxTokens,
|
|
94
|
+
messages,
|
|
70
95
|
};
|
|
71
96
|
|
|
72
|
-
if (
|
|
73
|
-
|
|
97
|
+
if (systemFinal) {
|
|
98
|
+
requestBody.system = systemFinal;
|
|
74
99
|
}
|
|
75
100
|
|
|
76
|
-
if (options.
|
|
77
|
-
|
|
78
|
-
type: 'json_schema',
|
|
79
|
-
schema: options.schema,
|
|
80
|
-
};
|
|
101
|
+
if (options.temperature !== undefined) {
|
|
102
|
+
requestBody.temperature = options.temperature;
|
|
81
103
|
}
|
|
82
104
|
|
|
83
|
-
let
|
|
84
|
-
let structuredOutput = null;
|
|
85
|
-
let usage = null;
|
|
86
|
-
let totalCostUSD = 0;
|
|
105
|
+
let raw;
|
|
87
106
|
|
|
88
107
|
try {
|
|
89
|
-
|
|
90
|
-
// Collect text from assistant messages
|
|
91
|
-
if (message.type === 'assistant') {
|
|
92
|
-
for (const block of message.message?.content || []) {
|
|
93
|
-
if (block.type === 'text') {
|
|
94
|
-
resultText += block.text;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Capture final result + usage from the result message
|
|
100
|
-
if (message.type === 'result') {
|
|
101
|
-
if (message.subtype === 'success') {
|
|
102
|
-
structuredOutput = message.structured_output || null;
|
|
103
|
-
resultText = message.result || resultText;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
usage = message.usage;
|
|
107
|
-
totalCostUSD = message.total_cost_usd || 0;
|
|
108
|
-
|
|
109
|
-
if (message.is_error) {
|
|
110
|
-
throw new Error(`claude-code: ${resultText || 'unknown error'}`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
108
|
+
raw = await client.messages.create(requestBody);
|
|
114
109
|
} catch (e) {
|
|
115
|
-
assistant?.error?.(`claude-code request failed: ${e.message}
|
|
110
|
+
assistant?.error?.(`claude-code request failed: ${e.message}`, e);
|
|
116
111
|
throw e;
|
|
117
112
|
}
|
|
118
113
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
self.tokens.total.price = totalCostUSD;
|
|
125
|
-
}
|
|
114
|
+
const outputText = (raw.content || [])
|
|
115
|
+
.filter((c) => c.type === 'text')
|
|
116
|
+
.map((c) => c.text.trim())
|
|
117
|
+
.join('\n')
|
|
118
|
+
.trim();
|
|
126
119
|
|
|
127
|
-
|
|
128
|
-
let content;
|
|
120
|
+
const modelConfig = MODEL_TABLE[options.model] || MODEL_TABLE[DEFAULT_MODEL];
|
|
129
121
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
122
|
+
self.tokens.input.count += raw.usage?.input_tokens || 0;
|
|
123
|
+
self.tokens.output.count += raw.usage?.output_tokens || 0;
|
|
124
|
+
self.tokens.total.count = self.tokens.input.count + self.tokens.output.count;
|
|
125
|
+
self.tokens.input.price = (self.tokens.input.count * modelConfig.input) / 1_000_000;
|
|
126
|
+
self.tokens.output.price = (self.tokens.output.count * modelConfig.output) / 1_000_000;
|
|
127
|
+
self.tokens.total.price = self.tokens.input.price + self.tokens.output.price;
|
|
128
|
+
|
|
129
|
+
let parsed = outputText;
|
|
137
130
|
|
|
138
|
-
|
|
131
|
+
if (options.response === 'json') {
|
|
132
|
+
parsed = parseJsonLoose(outputText);
|
|
133
|
+
}
|
|
139
134
|
|
|
140
135
|
return {
|
|
141
|
-
output:
|
|
142
|
-
content,
|
|
136
|
+
output: raw.content || [],
|
|
137
|
+
content: parsed,
|
|
143
138
|
tokens: self.tokens,
|
|
144
|
-
raw
|
|
139
|
+
raw,
|
|
145
140
|
};
|
|
146
141
|
};
|
|
147
142
|
|
|
148
143
|
/**
|
|
149
|
-
*
|
|
144
|
+
* Build Anthropic system + messages from the unified option shape.
|
|
145
|
+
*
|
|
146
|
+
* Accepts either:
|
|
147
|
+
* - options.messages: [{ role: 'system'|'user'|'assistant', content: string }]
|
|
148
|
+
* - options.prompt.content (system) + options.message.content (user)
|
|
150
149
|
*/
|
|
151
|
-
function
|
|
150
|
+
function buildMessages(options) {
|
|
152
151
|
if (Array.isArray(options.messages) && options.messages.length) {
|
|
153
152
|
const system = options.messages.find((m) => m.role === 'system')?.content;
|
|
154
|
-
const
|
|
153
|
+
const messages = options.messages
|
|
154
|
+
.filter((m) => m.role !== 'system')
|
|
155
|
+
.map((m) => ({ role: m.role, content: stringifyContent(m.content) }));
|
|
155
156
|
|
|
156
|
-
return {
|
|
157
|
-
system: stringifyContent(system),
|
|
158
|
-
prompt: stringifyContent(lastUser?.content),
|
|
159
|
-
};
|
|
157
|
+
return { system: stringifyContent(system), messages };
|
|
160
158
|
}
|
|
161
159
|
|
|
160
|
+
const system = stringifyContent(options.prompt?.content || '');
|
|
161
|
+
const userContent = stringifyContent(options.message?.content || '');
|
|
162
|
+
|
|
162
163
|
return {
|
|
163
|
-
system
|
|
164
|
-
|
|
164
|
+
system,
|
|
165
|
+
messages: [{ role: 'user', content: userContent }],
|
|
165
166
|
};
|
|
166
167
|
}
|
|
167
168
|
|
|
@@ -185,19 +186,22 @@ function stringifyContent(content) {
|
|
|
185
186
|
}
|
|
186
187
|
|
|
187
188
|
function parseJsonLoose(text) {
|
|
188
|
-
|
|
189
|
+
let cleaned = (text || '').trim();
|
|
190
|
+
|
|
191
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
|
|
189
192
|
|
|
190
|
-
|
|
191
|
-
const firstObj = cleaned.indexOf('{');
|
|
192
|
-
const firstArr = cleaned.indexOf('[');
|
|
193
|
-
const start = [firstObj, firstArr].filter((i) => i >= 0).sort((a, b) => a - b)[0];
|
|
193
|
+
const candidates = [cleaned.indexOf('{'), cleaned.indexOf('[')].filter((i) => i >= 0);
|
|
194
194
|
|
|
195
|
-
if (
|
|
196
|
-
|
|
195
|
+
if (candidates.length) {
|
|
196
|
+
const firstBrace = Math.min(...candidates);
|
|
197
|
+
|
|
198
|
+
if (firstBrace > 0) {
|
|
199
|
+
cleaned = cleaned.slice(firstBrace);
|
|
200
|
+
}
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
try {
|
|
200
|
-
return
|
|
204
|
+
return JSON5.parse(cleaned);
|
|
201
205
|
} catch {
|
|
202
206
|
return text;
|
|
203
207
|
}
|
|
@@ -59,7 +59,7 @@ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
59
59
|
* @param {string} [args.subject] - Newsletter subject. Embedded in the commit message so
|
|
60
60
|
* `git log` reads as a human-browseable history.
|
|
61
61
|
* @param {string} [args.commitMessage] - Full override of the default commit message
|
|
62
|
-
* @param {string} [args.token] - GitHub token (defaults to process.env.
|
|
62
|
+
* @param {string} [args.token] - GitHub token (defaults to process.env.GH_TOKEN)
|
|
63
63
|
* @param {object} [args.assistant] - logger
|
|
64
64
|
* @returns {Promise<{ urls: string[], paths: string[], htmlUrl?: string, htmlPath?: string, folderUrl: string, commitSha: string }>}
|
|
65
65
|
*/
|
|
@@ -76,10 +76,10 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
|
|
|
76
76
|
validateBrandId(brandId);
|
|
77
77
|
validateCampaignId(campaignId);
|
|
78
78
|
|
|
79
|
-
const githubToken = token || process.env.
|
|
79
|
+
const githubToken = token || process.env.GH_TOKEN;
|
|
80
80
|
|
|
81
81
|
if (!githubToken) {
|
|
82
|
-
throw new Error('image-host:
|
|
82
|
+
throw new Error('image-host: GH_TOKEN env var (or token arg) is required');
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
const log = (msg) => assistant?.log ? assistant.log(`[image-host] ${msg}`) : null;
|
|
@@ -37,7 +37,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// Check for GitHub configuration
|
|
40
|
-
if (!process.env.
|
|
40
|
+
if (!process.env.GH_TOKEN) {
|
|
41
41
|
return assistant.respond('GitHub API key not configured.', { code: 500 });
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -52,7 +52,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
52
52
|
|
|
53
53
|
// Setup Octokit
|
|
54
54
|
const octokit = new Octokit({
|
|
55
|
-
auth: process.env.
|
|
55
|
+
auth: process.env.GH_TOKEN,
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
// Check for required values
|
|
@@ -20,7 +20,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// Check for GitHub configuration
|
|
23
|
-
if (!process.env.
|
|
23
|
+
if (!process.env.GH_TOKEN) {
|
|
24
24
|
return assistant.respond('GitHub API key not configured.', { code: 500 });
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -35,7 +35,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
35
35
|
|
|
36
36
|
// Setup Octokit
|
|
37
37
|
const octokit = new Octokit({
|
|
38
|
-
auth: process.env.
|
|
38
|
+
auth: process.env.GH_TOKEN,
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
// Check for required values
|
|
@@ -17,7 +17,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// Check for GitHub configuration
|
|
20
|
-
if (!process.env.
|
|
20
|
+
if (!process.env.GH_TOKEN) {
|
|
21
21
|
return assistant.respond('GitHub API key not configured.', { code: 500 });
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -31,7 +31,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
31
31
|
|
|
32
32
|
// Setup Octokit
|
|
33
33
|
const octokit = new Octokit({
|
|
34
|
-
auth: process.env.
|
|
34
|
+
auth: process.env.GH_TOKEN,
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
// Check for required values
|
|
@@ -8,7 +8,7 @@ const { parse } = require('yaml');
|
|
|
8
8
|
module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
9
9
|
|
|
10
10
|
// Check for GitHub configuration
|
|
11
|
-
if (!process.env.
|
|
11
|
+
if (!process.env.GH_TOKEN) {
|
|
12
12
|
return assistant.respond('GitHub API key not configured.', { code: 500 });
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -18,7 +18,7 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
18
18
|
|
|
19
19
|
// Setup Octokit
|
|
20
20
|
const octokit = new Octokit({
|
|
21
|
-
auth: process.env.
|
|
21
|
+
auth: process.env.GH_TOKEN,
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
// Check for required parameters
|
|
@@ -4,14 +4,13 @@ const { validate: validateEmail, isDisposable } = require('../../../libraries/em
|
|
|
4
4
|
|
|
5
5
|
const MAX_POLL_TIME_MS = 30000;
|
|
6
6
|
const POLL_INTERVAL_MS = 500;
|
|
7
|
-
const MAX_ACCOUNT_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* POST /user/signup - Complete user signup
|
|
11
10
|
*
|
|
12
11
|
* Called by client after account creation to:
|
|
13
12
|
* 1. Poll for user doc to exist (waits for onCreate to complete)
|
|
14
|
-
* 2. Validate (
|
|
13
|
+
* 2. Validate (reject only if flags.signupProcessed is already true)
|
|
15
14
|
* 3. Gather all data (client details, inferred contact)
|
|
16
15
|
* 4. Write everything to user doc in one merge
|
|
17
16
|
* 5. Process affiliate referral (writes to referrer's doc)
|
|
@@ -49,23 +48,20 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
|
|
|
49
48
|
return assistant.respond('Signup has already been processed', { code: 400 });
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
// 3.
|
|
51
|
+
// 3. Fetch the Auth user — needed for the canonical creationTime used to stamp
|
|
52
|
+
// metadata.created and consent timestamps. flags.signupProcessed (checked above) is
|
|
53
|
+
// the sole idempotency gate; there is intentionally no account-age window, so a
|
|
54
|
+
// legitimately-unprocessed account can complete signup whenever it retries.
|
|
53
55
|
const authUser = await admin.auth().getUser(uid).catch((e) => e);
|
|
54
56
|
|
|
55
57
|
if (authUser instanceof Error) {
|
|
56
58
|
return assistant.respond(`Failed to get auth user: ${authUser.message}`, { code: 500 });
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
const accountAgeMs = Date.now() - new Date(authUser.metadata.creationTime).getTime();
|
|
60
|
-
|
|
61
|
-
if (accountAgeMs > MAX_ACCOUNT_AGE_MS) {
|
|
62
|
-
return assistant.respond('Account is too old to process signup', { code: 400 });
|
|
63
|
-
}
|
|
64
|
-
|
|
65
61
|
// 4. Gather all data, then write once
|
|
66
62
|
const email = user.auth.email;
|
|
67
63
|
const inferred = await inferUserContact(assistant, email);
|
|
68
|
-
const userRecord = buildUserRecord(assistant, settings, inferred);
|
|
64
|
+
const userRecord = buildUserRecord(assistant, settings, inferred, authUser.metadata.creationTime);
|
|
69
65
|
|
|
70
66
|
assistant.log(`signup(): Writing user record for ${uid}`, userRecord);
|
|
71
67
|
|
|
@@ -117,7 +113,7 @@ async function pollForUserDoc(assistant, uid) {
|
|
|
117
113
|
/**
|
|
118
114
|
* Build the full user record: client details, attribution, and inferred contact
|
|
119
115
|
*/
|
|
120
|
-
function buildUserRecord(assistant, settings, inferred) {
|
|
116
|
+
function buildUserRecord(assistant, settings, inferred, creationTime) {
|
|
121
117
|
const Manager = assistant.Manager;
|
|
122
118
|
const attribution = settings.attribution;
|
|
123
119
|
|
|
@@ -142,10 +138,22 @@ function buildUserRecord(assistant, settings, inferred) {
|
|
|
142
138
|
},
|
|
143
139
|
},
|
|
144
140
|
attribution: attribution || {},
|
|
145
|
-
consent: buildConsentRecord(assistant, settings.consent),
|
|
141
|
+
consent: buildConsentRecord(assistant, settings.consent, creationTime),
|
|
146
142
|
metadata: Manager.Metadata().set({ tag: 'user/signup' }),
|
|
147
143
|
};
|
|
148
144
|
|
|
145
|
+
// Stamp metadata.created from Firebase Auth's creationTime so it matches Auth's canonical
|
|
146
|
+
// value. Normally onCreate sets this, but if onCreate didn't fire this merge write is the
|
|
147
|
+
// doc's first creation — without this the doc lands with no created date and the OMEGA
|
|
148
|
+
// migration has to backfill it. Idempotent: when onCreate already wrote it, this matches.
|
|
149
|
+
if (creationTime) {
|
|
150
|
+
const createdDate = new Date(creationTime);
|
|
151
|
+
record.metadata.created = {
|
|
152
|
+
timestamp: createdDate.toISOString(),
|
|
153
|
+
timestampUNIX: Math.round(createdDate.getTime() / 1000),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
149
157
|
// Add inferred name/company if available
|
|
150
158
|
if (inferred) {
|
|
151
159
|
record.personal = {
|
|
@@ -168,18 +176,24 @@ function buildUserRecord(assistant, settings, inferred) {
|
|
|
168
176
|
* Client sends: { legal: { granted, text }, marketing: { granted, text } }
|
|
169
177
|
* Server writes: { legal: { status, grantedAt: {...} }, marketing: { status, grantedAt: {...}, revokedAt: {...} } }
|
|
170
178
|
*
|
|
171
|
-
* Server time (not client-supplied) is authoritative — defends against clock
|
|
179
|
+
* Server-derived time (not client-supplied) is authoritative — defends against clock
|
|
180
|
+
* manipulation. Uses Auth's creationTime so consent timestamps match metadata.created.
|
|
172
181
|
* IP is captured from request geolocation.
|
|
173
182
|
*
|
|
174
183
|
* Legal is REQUIRED — the client must send legal.granted=true. If missing/false we still
|
|
175
184
|
* record what the client sent, but the route will not have reached this point in practice
|
|
176
185
|
* (the signup-form HTML5-requires the legal checkbox).
|
|
177
186
|
*/
|
|
178
|
-
function buildConsentRecord(assistant, clientConsent) {
|
|
187
|
+
function buildConsentRecord(assistant, clientConsent, creationTime) {
|
|
179
188
|
const consent = clientConsent || {};
|
|
180
189
|
const ip = assistant.request.geolocation?.ip || null;
|
|
181
|
-
|
|
182
|
-
|
|
190
|
+
|
|
191
|
+
// Stamp grantedAt/revokedAt from Auth's creationTime so consent timestamps match
|
|
192
|
+
// metadata.created (the OMEGA migration treats metadata.created as the SSOT and reconciles
|
|
193
|
+
// consent.grantedAt against it). Fall back to request start time if creationTime is absent.
|
|
194
|
+
const createdDate = creationTime ? new Date(creationTime) : null;
|
|
195
|
+
const timestamp = createdDate ? createdDate.toISOString() : assistant.meta.startTime.timestamp;
|
|
196
|
+
const timestampUNIX = createdDate ? Math.round(createdDate.getTime() / 1000) : assistant.meta.startTime.timestampUNIX;
|
|
183
197
|
|
|
184
198
|
// Build empty leaf shape — used wherever grantedAt or revokedAt is "not set"
|
|
185
199
|
const emptyMeta = { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null };
|
package/templates/_.env
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
# ========== Default Values ==========
|
|
2
|
+
# GitHub
|
|
3
|
+
GH_TOKEN=""
|
|
4
|
+
|
|
2
5
|
# Backend Manager
|
|
3
6
|
BACKEND_MANAGER_KEY=""
|
|
4
7
|
BACKEND_MANAGER_WEBHOOK_KEY=""
|
|
@@ -6,9 +9,6 @@ BACKEND_MANAGER_NAMESPACE=""
|
|
|
6
9
|
BACKEND_MANAGER_OPENAI_API_KEY=""
|
|
7
10
|
BACKEND_MANAGER_ANTHROPIC_API_KEY=""
|
|
8
11
|
|
|
9
|
-
# GitHub
|
|
10
|
-
GITHUB_TOKEN=""
|
|
11
|
-
|
|
12
12
|
# AI
|
|
13
13
|
OPENAI_API_KEY=""
|
|
14
14
|
ANTHROPIC_API_KEY=""
|
|
@@ -112,7 +112,7 @@ module.exports = {
|
|
|
112
112
|
name: 'create-test-post',
|
|
113
113
|
auth: 'admin',
|
|
114
114
|
timeout: 60000,
|
|
115
|
-
skip: !process.env.
|
|
115
|
+
skip: !process.env.GH_TOKEN ? 'GH_TOKEN env var not set' : false,
|
|
116
116
|
|
|
117
117
|
async run({ assert, state, config }) {
|
|
118
118
|
if (!config.github?.repo_website) {
|
|
@@ -120,7 +120,7 @@ module.exports = {
|
|
|
120
120
|
return;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
const octokit = new Octokit({ auth: process.env.
|
|
123
|
+
const octokit = new Octokit({ auth: process.env.GH_TOKEN });
|
|
124
124
|
|
|
125
125
|
// Parse owner/repo from githubRepoWebsite
|
|
126
126
|
const repoMatch = config.github?.repo_website.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
@@ -181,7 +181,7 @@ module.exports = {
|
|
|
181
181
|
name: 'verify-file-exists',
|
|
182
182
|
auth: 'admin',
|
|
183
183
|
timeout: 30000,
|
|
184
|
-
skip: !process.env.
|
|
184
|
+
skip: !process.env.GH_TOKEN ? 'GH_TOKEN env var not set' : false,
|
|
185
185
|
|
|
186
186
|
async run({ assert, state, config }) {
|
|
187
187
|
if (!state.postPath) {
|
|
@@ -190,7 +190,7 @@ module.exports = {
|
|
|
190
190
|
|
|
191
191
|
state.postUrl = `https://${config.domain}/blog/${state.postSlug}`;
|
|
192
192
|
|
|
193
|
-
const octokit = new Octokit({ auth: process.env.
|
|
193
|
+
const octokit = new Octokit({ auth: process.env.GH_TOKEN });
|
|
194
194
|
|
|
195
195
|
// Verify the file exists via direct API (not code search)
|
|
196
196
|
const { data: fileData } = await octokit.rest.repos.getContent({
|
|
@@ -211,7 +211,7 @@ module.exports = {
|
|
|
211
211
|
name: 'edit-test-post',
|
|
212
212
|
auth: 'admin',
|
|
213
213
|
timeout: 60000,
|
|
214
|
-
skip: !process.env.
|
|
214
|
+
skip: !process.env.GH_TOKEN ? 'GH_TOKEN env var not set' : false,
|
|
215
215
|
|
|
216
216
|
async run({ http, assert, state }) {
|
|
217
217
|
if (!state.postUrl) {
|
|
@@ -244,7 +244,7 @@ module.exports = {
|
|
|
244
244
|
name: 'verify-edit',
|
|
245
245
|
auth: 'admin',
|
|
246
246
|
timeout: 30000,
|
|
247
|
-
skip: !process.env.
|
|
247
|
+
skip: !process.env.GH_TOKEN ? 'GH_TOKEN env var not set' : false,
|
|
248
248
|
|
|
249
249
|
async run({ assert, state }) {
|
|
250
250
|
if (!state.postPath || state.editSkipped) {
|
|
@@ -252,7 +252,7 @@ module.exports = {
|
|
|
252
252
|
return;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
const octokit = new Octokit({ auth: process.env.
|
|
255
|
+
const octokit = new Octokit({ auth: process.env.GH_TOKEN });
|
|
256
256
|
|
|
257
257
|
// Fetch the file directly from GitHub
|
|
258
258
|
const { data: fileData } = await octokit.rest.repos.getContent({
|
|
@@ -309,11 +309,11 @@ module.exports = {
|
|
|
309
309
|
timeout: 60000,
|
|
310
310
|
|
|
311
311
|
async run({ state }) {
|
|
312
|
-
if (!process.env.
|
|
312
|
+
if (!process.env.GH_TOKEN || !state.postPath) {
|
|
313
313
|
return;
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
const octokit = new Octokit({ auth: process.env.
|
|
316
|
+
const octokit = new Octokit({ auth: process.env.GH_TOKEN });
|
|
317
317
|
|
|
318
318
|
// Get the current SHA (may have changed after edit)
|
|
319
319
|
try {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Writes arbitrary content to a GitHub repository
|
|
5
5
|
* Requires admin/blogger role, GitHub API key, and repo_website config
|
|
6
6
|
*
|
|
7
|
-
* IMPORTANT: These tests require
|
|
7
|
+
* IMPORTANT: These tests require GH_TOKEN and github.repo_website to be configured.
|
|
8
8
|
* If GitHub is not configured, the tests will fail.
|
|
9
9
|
*
|
|
10
10
|
* This is a suite because we need to clean up created files and cancel workflows after tests.
|
|
@@ -155,11 +155,11 @@ module.exports = {
|
|
|
155
155
|
timeout: 60000,
|
|
156
156
|
|
|
157
157
|
async run({ state, config }) {
|
|
158
|
-
if (!process.env.
|
|
158
|
+
if (!process.env.GH_TOKEN || !config.github?.repo_website) {
|
|
159
159
|
return;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
const octokit = new Octokit({ auth: process.env.
|
|
162
|
+
const octokit = new Octokit({ auth: process.env.GH_TOKEN });
|
|
163
163
|
|
|
164
164
|
// Parse owner/repo from githubRepoWebsite (e.g., 'https://github.com/owner/repo')
|
|
165
165
|
const repoMatch = config.github?.repo_website.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
@@ -111,8 +111,8 @@ module.exports = {
|
|
|
111
111
|
skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE env var not set' : false,
|
|
112
112
|
|
|
113
113
|
async run({ http, assert, state, config }) {
|
|
114
|
-
if (!process.env.
|
|
115
|
-
assert.fail('
|
|
114
|
+
if (!process.env.GH_TOKEN) {
|
|
115
|
+
assert.fail('GH_TOKEN env var not set');
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
118
|
|
|
@@ -167,7 +167,7 @@ module.exports = {
|
|
|
167
167
|
return; // Previous test didn't run
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
const octokit = new Octokit({ auth: process.env.
|
|
170
|
+
const octokit = new Octokit({ auth: process.env.GH_TOKEN });
|
|
171
171
|
|
|
172
172
|
// Fetch the committed post from GitHub
|
|
173
173
|
const { data: fileData } = await octokit.rest.repos.getContent({
|
|
@@ -203,7 +203,7 @@ module.exports = {
|
|
|
203
203
|
return; // Previous test didn't run
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
const octokit = new Octokit({ auth: process.env.
|
|
206
|
+
const octokit = new Octokit({ auth: process.env.GH_TOKEN });
|
|
207
207
|
const imageDir = `src/assets/images/blog/post-${state.postId}/`;
|
|
208
208
|
|
|
209
209
|
// List committed images and pick the header (matches the slugified URL "bem-test-create-post")
|
|
@@ -285,11 +285,11 @@ module.exports = {
|
|
|
285
285
|
timeout: 60000,
|
|
286
286
|
|
|
287
287
|
async run({ state }) {
|
|
288
|
-
if (!process.env.
|
|
288
|
+
if (!process.env.GH_TOKEN || !state.postPath) {
|
|
289
289
|
return;
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
const octokit = new Octokit({ auth: process.env.
|
|
292
|
+
const octokit = new Octokit({ auth: process.env.GH_TOKEN });
|
|
293
293
|
|
|
294
294
|
// Delete the test post
|
|
295
295
|
try {
|
|
@@ -114,7 +114,7 @@ module.exports = {
|
|
|
114
114
|
name: 'create-test-post',
|
|
115
115
|
auth: 'admin',
|
|
116
116
|
timeout: 60000,
|
|
117
|
-
skip: !process.env.
|
|
117
|
+
skip: !process.env.GH_TOKEN ? 'GH_TOKEN env var not set' : false,
|
|
118
118
|
|
|
119
119
|
async run({ assert, state, config }) {
|
|
120
120
|
if (!config.github?.repo_website) {
|
|
@@ -122,7 +122,7 @@ module.exports = {
|
|
|
122
122
|
return;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
const octokit = new Octokit({ auth: process.env.
|
|
125
|
+
const octokit = new Octokit({ auth: process.env.GH_TOKEN });
|
|
126
126
|
|
|
127
127
|
// Parse owner/repo from githubRepoWebsite
|
|
128
128
|
const repoMatch = config.github?.repo_website.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
@@ -183,7 +183,7 @@ module.exports = {
|
|
|
183
183
|
name: 'verify-file-exists',
|
|
184
184
|
auth: 'admin',
|
|
185
185
|
timeout: 30000,
|
|
186
|
-
skip: !process.env.
|
|
186
|
+
skip: !process.env.GH_TOKEN ? 'GH_TOKEN env var not set' : false,
|
|
187
187
|
|
|
188
188
|
async run({ assert, state, config }) {
|
|
189
189
|
if (!state.postPath) {
|
|
@@ -192,7 +192,7 @@ module.exports = {
|
|
|
192
192
|
|
|
193
193
|
state.postUrl = `https://${config.domain}/blog/${state.postSlug}`;
|
|
194
194
|
|
|
195
|
-
const octokit = new Octokit({ auth: process.env.
|
|
195
|
+
const octokit = new Octokit({ auth: process.env.GH_TOKEN });
|
|
196
196
|
|
|
197
197
|
// Verify the file exists via direct API (not code search)
|
|
198
198
|
const { data: fileData } = await octokit.rest.repos.getContent({
|
|
@@ -213,7 +213,7 @@ module.exports = {
|
|
|
213
213
|
name: 'edit-test-post',
|
|
214
214
|
auth: 'admin',
|
|
215
215
|
timeout: 60000,
|
|
216
|
-
skip: !process.env.
|
|
216
|
+
skip: !process.env.GH_TOKEN ? 'GH_TOKEN env var not set' : false,
|
|
217
217
|
|
|
218
218
|
async run({ http, assert, state }) {
|
|
219
219
|
if (!state.postUrl) {
|
|
@@ -246,7 +246,7 @@ module.exports = {
|
|
|
246
246
|
name: 'verify-edit',
|
|
247
247
|
auth: 'admin',
|
|
248
248
|
timeout: 30000,
|
|
249
|
-
skip: !process.env.
|
|
249
|
+
skip: !process.env.GH_TOKEN ? 'GH_TOKEN env var not set' : false,
|
|
250
250
|
|
|
251
251
|
async run({ assert, state }) {
|
|
252
252
|
if (!state.postPath || state.editSkipped) {
|
|
@@ -254,7 +254,7 @@ module.exports = {
|
|
|
254
254
|
return;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
const octokit = new Octokit({ auth: process.env.
|
|
257
|
+
const octokit = new Octokit({ auth: process.env.GH_TOKEN });
|
|
258
258
|
|
|
259
259
|
// Fetch the file directly from GitHub
|
|
260
260
|
const { data: fileData } = await octokit.rest.repos.getContent({
|
|
@@ -311,11 +311,11 @@ module.exports = {
|
|
|
311
311
|
timeout: 60000,
|
|
312
312
|
|
|
313
313
|
async run({ state }) {
|
|
314
|
-
if (!process.env.
|
|
314
|
+
if (!process.env.GH_TOKEN || !state.postPath) {
|
|
315
315
|
return;
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
-
const octokit = new Octokit({ auth: process.env.
|
|
318
|
+
const octokit = new Octokit({ auth: process.env.GH_TOKEN });
|
|
319
319
|
|
|
320
320
|
// Get the current SHA (may have changed after edit)
|
|
321
321
|
try {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Writes arbitrary content to a GitHub repository
|
|
5
5
|
* Requires admin/blogger role, GitHub API key, and repo_website config
|
|
6
6
|
*
|
|
7
|
-
* IMPORTANT: These tests require
|
|
7
|
+
* IMPORTANT: These tests require GH_TOKEN and github.repo_website to be configured.
|
|
8
8
|
* If GitHub is not configured, the tests will fail.
|
|
9
9
|
*
|
|
10
10
|
* This is a suite because we need to clean up created files and cancel workflows after tests.
|
|
@@ -155,11 +155,11 @@ module.exports = {
|
|
|
155
155
|
timeout: 60000,
|
|
156
156
|
|
|
157
157
|
async run({ state, config }) {
|
|
158
|
-
if (!process.env.
|
|
158
|
+
if (!process.env.GH_TOKEN || !config.github?.repo_website) {
|
|
159
159
|
return;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
const octokit = new Octokit({ auth: process.env.
|
|
162
|
+
const octokit = new Octokit({ auth: process.env.GH_TOKEN });
|
|
163
163
|
|
|
164
164
|
// Parse owner/repo from githubRepoWebsite (e.g., 'https://github.com/owner/repo')
|
|
165
165
|
const repoMatch = config.github?.repo_website.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
@@ -244,8 +244,6 @@ module.exports = {
|
|
|
244
244
|
};
|
|
245
245
|
|
|
246
246
|
// Use absurdly-old client timestamp to prove server time wins (defense vs clock skew)
|
|
247
|
-
const beforeMs = Date.now();
|
|
248
|
-
|
|
249
247
|
const signupResponse = await http.as('consent-granted').post('user/signup', {
|
|
250
248
|
consent: {
|
|
251
249
|
legal: { granted: true, text: consentText.legal, timestamp: '2000-01-01T00:00:00.000Z' },
|
|
@@ -255,7 +253,6 @@ module.exports = {
|
|
|
255
253
|
|
|
256
254
|
assert.isSuccess(signupResponse, `Signup should succeed: ${JSON.stringify(signupResponse, null, 2)}`);
|
|
257
255
|
|
|
258
|
-
const afterMs = Date.now();
|
|
259
256
|
const userDoc = await firestore.get(`users/${accounts['consent-granted'].uid}`);
|
|
260
257
|
|
|
261
258
|
// Legal
|
|
@@ -265,14 +262,16 @@ module.exports = {
|
|
|
265
262
|
assert.ok(userDoc?.consent?.legal?.grantedAt?.timestamp, 'legal grantedAt.timestamp should be set');
|
|
266
263
|
assert.equal(typeof userDoc?.consent?.legal?.grantedAt?.timestampUNIX, 'number', 'legal grantedAt.timestampUNIX should be number');
|
|
267
264
|
|
|
268
|
-
// Server time MUST be used (the client-supplied 2000-01-01 should NOT appear)
|
|
265
|
+
// Server-derived time MUST be used (the client-supplied 2000-01-01 should NOT appear).
|
|
266
|
+
// grantedAt is stamped from Auth's creationTime, the same source as metadata.created,
|
|
267
|
+
// so the two must be equal — and the client's 2000-01-01 (UNIX 946684800) is rejected.
|
|
269
268
|
const legalUNIX = userDoc.consent.legal.grantedAt.timestampUNIX;
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
legalUNIX
|
|
274
|
-
`legal grantedAt.timestampUNIX (${legalUNIX}) must be server time, not client time. expected between ${beforeUNIX} and ${afterUNIX}`
|
|
269
|
+
const createdUNIX = userDoc.metadata.created.timestampUNIX;
|
|
270
|
+
assert.equal(
|
|
271
|
+
legalUNIX, createdUNIX,
|
|
272
|
+
`legal grantedAt.timestampUNIX (${legalUNIX}) should match metadata.created (${createdUNIX}) — both from Auth creationTime`
|
|
275
273
|
);
|
|
274
|
+
assert.notEqual(legalUNIX, 946684800, 'legal grantedAt must NOT be the client-supplied 2000-01-01 time');
|
|
276
275
|
|
|
277
276
|
// Marketing
|
|
278
277
|
assert.equal(userDoc?.consent?.marketing?.status, 'granted', 'consent.marketing.status should be granted');
|