@galda/cli 0.10.27 → 0.10.32
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/app/index.html +886 -105
- package/engine/lib.mjs +43 -4
- package/engine/server.mjs +118 -36
- package/package.json +1 -1
package/engine/lib.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { resolve, join, basename } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
6
|
-
import { createPublicKey, verify as verifyRsaSignature } from 'node:crypto';
|
|
6
|
+
import { createPublicKey, verify as verifyRsaSignature, createHash } from 'node:crypto';
|
|
7
7
|
|
|
8
8
|
// Worker agents Galda can drive. Galda is NOT tied to Claude Code — it runs on
|
|
9
9
|
// whichever agent CLI you have. Neither is auto-installed and codex is never even
|
|
@@ -2775,8 +2775,11 @@ export function nextResumeDelay(attempt, { baseMs = 5 * 60 * 1000, maxMs = 60 *
|
|
|
2775
2775
|
|
|
2776
2776
|
// ---- billing entitlement gate (public self-serve launch, docs/BILLING-LAUNCH-PLAN.md) --
|
|
2777
2777
|
// Mirrors workers/billing-api/src/entitlement.mjs's FREE_TIER_LIMITS/
|
|
2778
|
-
// checkFreeTierLimit/resolveEntitlement exactly (Masa
|
|
2779
|
-
//
|
|
2778
|
+
// checkFreeTierLimit/resolveEntitlement exactly (Masa: 5 queued to-dos,
|
|
2779
|
+
// 20 total tasks, 1 project — matching galda.app's own words. Updated
|
|
2780
|
+
// 2026-07-17: lifetime cap 19→20 so the wall says the same number the sales
|
|
2781
|
+
// page promises ("20 total tasks"); it used to block the 20th create, one short
|
|
2782
|
+
// of the promise. Pro unlocks all three; price is Stripe-derived, not in code.)
|
|
2780
2783
|
// Deliberately duplicated rather than imported: engine/ and workers/billing-api/
|
|
2781
2784
|
// are independently deployed (a user's Mac vs Cloudflare) and independently
|
|
2782
2785
|
// tested — sharing a module across that boundary would couple two things
|
|
@@ -2784,10 +2787,21 @@ export function nextResumeDelay(attempt, { baseMs = 5 * 60 * 1000, maxMs = 60 *
|
|
|
2784
2787
|
// hand; both have their own test coverage against the same free-tier numbers.
|
|
2785
2788
|
export const FREE_TIER_LIMITS = Object.freeze({
|
|
2786
2789
|
maxPendingGoals: 5,
|
|
2787
|
-
maxCumulativeGoals:
|
|
2790
|
+
maxCumulativeGoals: 20,
|
|
2788
2791
|
maxProjects: 1,
|
|
2789
2792
|
});
|
|
2790
2793
|
|
|
2794
|
+
// The statuses that occupy a "queued to-do" slot (the 5-slot wall). Narrower
|
|
2795
|
+
// than "every open goal" on purpose: a goal in `review` has finished its work
|
|
2796
|
+
// and is waiting on a human — that's not a to-do in flight, and galda.app sells
|
|
2797
|
+
// the slot as "queued to-dos", so review must not eat one (Masa 2026-07-17,
|
|
2798
|
+
// "5枠は実行待ちだけ"). Kept as its own named list rather than filtering an
|
|
2799
|
+
// "all open" constant so the intent is legible where it's read.
|
|
2800
|
+
export const QUEUED_GOAL_STATUSES = Object.freeze([
|
|
2801
|
+
'stacked', 'sending', 'pending', 'planning', 'needsInput',
|
|
2802
|
+
'running', 'partial', 'failed', 'interrupted', 'blocked', 'retesting',
|
|
2803
|
+
]);
|
|
2804
|
+
|
|
2791
2805
|
export function checkFreeTierLimit(usage) {
|
|
2792
2806
|
const pendingCount = Number(usage?.pendingCount) || 0;
|
|
2793
2807
|
const cumulativeCount = Number(usage?.cumulativeCount) || 0;
|
|
@@ -3320,3 +3334,28 @@ export function classifyRelayClientConflict({ existingLock, alive, newIdentity }
|
|
|
3320
3334
|
export function shouldEscalateConnectGateHint(waitedMs, email, thresholdMs = 15000) {
|
|
3321
3335
|
return Number(waitedMs) >= thresholdMs && Boolean(String(email ?? '').trim());
|
|
3322
3336
|
}
|
|
3337
|
+
|
|
3338
|
+
// --- device-flow (PKCE) sign-in helpers --------------------------------------
|
|
3339
|
+
// The browser no longer delivers the minted token to a live loopback port —
|
|
3340
|
+
// the engine POLLS the Worker to claim it (device-flow). PKCE ties the claim to
|
|
3341
|
+
// the engine that started the flow: the engine keeps a secret `verifier`, sends
|
|
3342
|
+
// only its SHA-256 `challenge` through the browser, and the Worker hands the
|
|
3343
|
+
// token back only to a claimant that can present the matching verifier. These
|
|
3344
|
+
// are pure so the crypto + response-shape parsing are unit-tested with no server.
|
|
3345
|
+
export function makeSigninChallenge(verifier) {
|
|
3346
|
+
return createHash('sha256').update(String(verifier ?? '')).digest('base64url');
|
|
3347
|
+
}
|
|
3348
|
+
export function verifierMatchesChallenge(verifier, challenge) {
|
|
3349
|
+
if (!verifier || !challenge) return false;
|
|
3350
|
+
return makeSigninChallenge(verifier) === String(challenge);
|
|
3351
|
+
}
|
|
3352
|
+
// Normalize the Worker's /connect/claim JSON into a single shape the poller can
|
|
3353
|
+
// switch on. Worker returns {pending:true} before the browser lands, {error} on
|
|
3354
|
+
// failure, {token,email} once minted. Anything malformed reads as an error so a
|
|
3355
|
+
// bad response never masquerades as "still pending" forever.
|
|
3356
|
+
export function parseClaimResponse(obj) {
|
|
3357
|
+
if (!obj || typeof obj !== 'object') return { state: 'error', error: 'malformed claim response' };
|
|
3358
|
+
if (obj.error) return { state: 'error', error: String(obj.error) };
|
|
3359
|
+
if (obj.token) return { state: 'ready', token: String(obj.token), email: obj.email != null ? String(obj.email) : undefined };
|
|
3360
|
+
return { state: 'pending' };
|
|
3361
|
+
}
|
package/engine/server.mjs
CHANGED
|
@@ -20,8 +20,8 @@ import { resolve, dirname, join, basename } from 'node:path';
|
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { pathToFileURL } from 'node:url';
|
|
23
|
-
import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText } from './lib.mjs';
|
|
24
|
-
import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision } from './lib.mjs';
|
|
23
|
+
import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
|
|
24
|
+
import { parseStreamEvents, parsePlan, refinePlanTasks, findOverlappingGoal, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, isUiChange, shouldCaptureProof, shouldCaptureBaseline, hasTestRelevantChanges, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision } from './lib.mjs';
|
|
25
25
|
import { openPR } from './pr.mjs';
|
|
26
26
|
import { runVerification, exerciseUi } from './verify.mjs';
|
|
27
27
|
|
|
@@ -177,6 +177,19 @@ const entitlementCache = new Map(); // billing email -> { isPaying, checkedAt }
|
|
|
177
177
|
// it survives restarts.
|
|
178
178
|
const licenseFile = join(DATA_DIR, 'license.token');
|
|
179
179
|
let licenseState = { email: null, isPaying: false, verifiedAt: 0 }; // in-memory cache of the last successful verify (isPaying is the token's snapshot; the live gate re-checks)
|
|
180
|
+
// Test-only seam: override the license-verify public key via env so tests can
|
|
181
|
+
// sign tokens the running server actually accepts (the baked LICENSE_PUBLIC_JWK
|
|
182
|
+
// has no committed private half, so the licensed-but-lapsed path is otherwise
|
|
183
|
+
// untestable — see docs/HANDOFF-A2C-WORKERS-DEV-CLEANUP.md item 6). Unset in
|
|
184
|
+
// prod → verifyLicenseToken falls back to the baked key. This is NOT a payment
|
|
185
|
+
// bypass: forging an identity here only picks WHO the email is; whether they're
|
|
186
|
+
// paying is still the live remote billing-api (fetchIsPaying). An env escape
|
|
187
|
+
// hatch already exists on the gate (MANAGER_ENTITLEMENT_BYPASS=1) — same spirit.
|
|
188
|
+
const LICENSE_PUBKEY_OVERRIDE = (() => {
|
|
189
|
+
const raw = process.env.MANAGER_LICENSE_PUBKEY;
|
|
190
|
+
if (!raw) return undefined;
|
|
191
|
+
try { return JSON.parse(raw); } catch { return undefined; }
|
|
192
|
+
})();
|
|
180
193
|
|
|
181
194
|
// Analytics identity (A+): once signed in, scope usage events to the ACCOUNT
|
|
182
195
|
// (the billing Worker hashes this email → the same account_id the accounts table
|
|
@@ -191,26 +204,72 @@ let _freeExhaustedEmitted = false;
|
|
|
191
204
|
// the nonce, round-trips it through the flow, and requires it back — so a random
|
|
192
205
|
// page can't drive the local /oauth/callback with a token the user never asked for.
|
|
193
206
|
const SIGNIN_NONCE_TTL_MS = 10 * 60 * 1000;
|
|
194
|
-
const pendingSignins = new Map(); // nonce -> expiresAt
|
|
207
|
+
const pendingSignins = new Map(); // nonce -> { expiresAt, verifier }
|
|
195
208
|
function newSigninNonce() {
|
|
196
209
|
const now = Date.now();
|
|
197
|
-
for (const [k,
|
|
210
|
+
for (const [k, v] of pendingSignins) if (v.expiresAt < now) pendingSignins.delete(k); // opportunistic sweep
|
|
198
211
|
const nonce = randomBytes(18).toString('base64url');
|
|
199
|
-
|
|
200
|
-
|
|
212
|
+
const verifier = randomBytes(32).toString('base64url'); // PKCE secret — never leaves the engine
|
|
213
|
+
pendingSignins.set(nonce, { expiresAt: now + SIGNIN_NONCE_TTL_MS, verifier });
|
|
214
|
+
return { nonce, verifier };
|
|
201
215
|
}
|
|
202
216
|
function consumeSigninNonce(nonce) {
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
217
|
+
const entry = pendingSignins.get(nonce);
|
|
218
|
+
if (entry === undefined) return false;
|
|
205
219
|
pendingSignins.delete(nonce);
|
|
206
|
-
return
|
|
220
|
+
return entry.expiresAt >= Date.now();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Device-flow (PKCE) sign-in. The old loopback flow delivered the minted token
|
|
224
|
+
// to http://127.0.0.1:<port>/oauth/callback — which dead-ends with
|
|
225
|
+
// ERR_CONNECTION_REFUSED the moment that ephemeral port is gone (engine
|
|
226
|
+
// restarted, MANAGER_PORT=0 instance exited). Instead the engine POLLS the
|
|
227
|
+
// Worker to claim the token and the browser lands on a HOSTED success page, so
|
|
228
|
+
// delivery never depends on the browser reaching a live local port.
|
|
229
|
+
const activeSigninPolls = new Set(); // nonces with a live poller (one per nonce)
|
|
230
|
+
function buildSigninUrl() {
|
|
231
|
+
const { nonce, verifier } = newSigninNonce();
|
|
232
|
+
const challenge = makeSigninChallenge(verifier);
|
|
233
|
+
startSigninPoll(nonce, verifier);
|
|
234
|
+
return `${BILLING_API_URL}/connect?state=${encodeURIComponent(nonce)}&challenge=${encodeURIComponent(challenge)}`;
|
|
235
|
+
}
|
|
236
|
+
// Best-effort background loop: GET /connect/claim every 2s until the token is
|
|
237
|
+
// minted (ready), an error is stored, or the nonce's 10-min TTL lapses. On
|
|
238
|
+
// success it activates the license and nudges the app over SSE (the same
|
|
239
|
+
// 'signed-in'/'signin-error' events the popup postMessage used to carry). Never
|
|
240
|
+
// throws — an unreachable Worker just means we try again next tick.
|
|
241
|
+
async function startSigninPoll(nonce, verifier) {
|
|
242
|
+
if (!BILLING_API_URL || activeSigninPolls.has(nonce)) return;
|
|
243
|
+
activeSigninPolls.add(nonce);
|
|
244
|
+
const deadline = Date.now() + SIGNIN_NONCE_TTL_MS;
|
|
245
|
+
const stop = () => { activeSigninPolls.delete(nonce); pendingSignins.delete(nonce); };
|
|
246
|
+
try {
|
|
247
|
+
while (Date.now() < deadline) {
|
|
248
|
+
await new Promise((r) => { const t = setTimeout(r, 2000); t.unref?.(); });
|
|
249
|
+
if (!activeSigninPolls.has(nonce)) return; // stopped elsewhere
|
|
250
|
+
try {
|
|
251
|
+
const url = `${BILLING_API_URL}/connect/claim?state=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(verifier)}`;
|
|
252
|
+
const res = await fetch(url);
|
|
253
|
+
if (!res.ok) continue; // 403 mismatch / transient — keep trying until TTL
|
|
254
|
+
const parsed = parseClaimResponse(await res.json().catch(() => null));
|
|
255
|
+
if (parsed.state === 'ready') {
|
|
256
|
+
const result = await activateLicense(parsed.token);
|
|
257
|
+
if (result.ok) send({ ev: 'signed-in', email: result.email });
|
|
258
|
+
else send({ ev: 'signin-error', error: result.error });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (parsed.state === 'error') { send({ ev: 'signin-error', error: parsed.error }); return; }
|
|
262
|
+
// pending → loop again
|
|
263
|
+
} catch { /* best-effort — retry next tick */ }
|
|
264
|
+
}
|
|
265
|
+
} finally { stop(); }
|
|
207
266
|
}
|
|
208
267
|
|
|
209
268
|
async function loadLicenseFromDisk() {
|
|
210
269
|
if (!existsSync(licenseFile)) return;
|
|
211
270
|
const token = readFileSync(licenseFile, 'utf8').trim();
|
|
212
271
|
if (!token) return;
|
|
213
|
-
const result = await verifyLicenseToken({ token });
|
|
272
|
+
const result = await verifyLicenseToken({ token, publicJwk: LICENSE_PUBKEY_OVERRIDE });
|
|
214
273
|
if (result.valid) licenseState = { email: result.payload.email, isPaying: Boolean(result.payload.isPaying), verifiedAt: Date.now() };
|
|
215
274
|
}
|
|
216
275
|
loadLicenseFromDisk();
|
|
@@ -219,7 +278,7 @@ loadLicenseFromDisk();
|
|
|
219
278
|
// token to disk that doesn't actually verify, so a bad paste can't silently
|
|
220
279
|
// wedge the app into "licensed but broken".
|
|
221
280
|
async function activateLicense(token) {
|
|
222
|
-
const result = await verifyLicenseToken({ token: String(token ?? '') });
|
|
281
|
+
const result = await verifyLicenseToken({ token: String(token ?? ''), publicJwk: LICENSE_PUBKEY_OVERRIDE });
|
|
223
282
|
if (!result.valid) return { ok: false, error: `invalid license: ${result.reason}` };
|
|
224
283
|
writeFileSync(licenseFile, String(token));
|
|
225
284
|
licenseState = { email: result.payload.email, isPaying: Boolean(result.payload.isPaying), verifiedAt: Date.now() };
|
|
@@ -250,26 +309,32 @@ function signinResultPage(outcome) {
|
|
|
250
309
|
`</script></body>`;
|
|
251
310
|
}
|
|
252
311
|
|
|
253
|
-
//
|
|
254
|
-
// status
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
// projects are shared/global
|
|
262
|
-
// stay shared for everyone" comment
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
312
|
+
// The 5-slot "queued to-dos" cap counts QUEUED_GOAL_STATUSES (from lib.mjs) —
|
|
313
|
+
// every open status EXCEPT `review`. A goal in review has finished its work and
|
|
314
|
+
// is waiting on a human, so it isn't a to-do in flight, and galda.app sells the
|
|
315
|
+
// slot as "queued to-dos" (Masa 2026-07-17, "5枠は実行待ちだけ"). Still broad
|
|
316
|
+
// otherwise, for the same reason RATE_LIMIT_RE is: undercounting would let
|
|
317
|
+
// someone quietly exceed the cap via an odd status. The list lives in lib.mjs
|
|
318
|
+
// so the wall's meaning is defined next to checkFreeTierLimit and unit-tested.
|
|
319
|
+
|
|
320
|
+
// `projectCount` is install-wide, not per-identity: projects are shared/global
|
|
321
|
+
// in this codebase (see the "Projects ... stay shared for everyone" comment
|
|
322
|
+
// above goalVisibleTo in lib.mjs) while only goals are partitioned by owner.
|
|
323
|
+
// That asymmetry used to leave this at 0 pending a product decision (a per-user
|
|
324
|
+
// "max 1 project" cap would have let one user's 2nd project block everyone
|
|
325
|
+
// else's). Resolved 2026-07-17 (Masa): the shipping product is local-first —
|
|
326
|
+
// app.galda.app reaches each user's OWN engine over the relay, so one install
|
|
327
|
+
// is one user, and the license this gate consults (licenseState.email) is
|
|
328
|
+
// already install-wide too. Counting every project on the install is therefore
|
|
329
|
+
// the same scope billing already runs at. The stale caveat only applies to the
|
|
330
|
+
// legacy Cloudflare Access multi-user mode, where several identities share one
|
|
331
|
+
// install AND one license — there, a shared cap is already the status quo.
|
|
267
332
|
function computeFreeTierUsage(identity) {
|
|
268
333
|
const owned = goals.filter((g) => (g.owner ?? 'owner') === identity);
|
|
269
334
|
return {
|
|
270
|
-
pendingCount: owned.filter((g) =>
|
|
335
|
+
pendingCount: owned.filter((g) => QUEUED_GOAL_STATUSES.includes(g.status)).length,
|
|
271
336
|
cumulativeCount: owned.length,
|
|
272
|
-
projectCount:
|
|
337
|
+
projectCount: projects.length,
|
|
273
338
|
};
|
|
274
339
|
}
|
|
275
340
|
|
|
@@ -362,15 +427,25 @@ async function getBillingPrice() {
|
|
|
362
427
|
// every installation (~/.claude/plans/toasty-rolling-marshmallow.md).
|
|
363
428
|
// A personal/dev instance that wants to skip billing entirely should opt in
|
|
364
429
|
// explicitly via MANAGER_ENTITLEMENT_BYPASS=1, not ride the 'owner' string.
|
|
365
|
-
async function requireEntitlementGate(identity) {
|
|
430
|
+
async function requireEntitlementGate(identity, intent = 'goal') {
|
|
366
431
|
if (process.env.MANAGER_ENTITLEMENT_BYPASS === '1') return { allowed: true, blocked: null };
|
|
367
|
-
// Count the
|
|
368
|
-
// only
|
|
432
|
+
// Count the thing being created (existing + 1): computeFreeTierUsage counts
|
|
433
|
+
// only what's already on file and this gate runs *before* the new one is
|
|
369
434
|
// added, so "5 pending / 19 lifetime max" must block the 6th / 20th create,
|
|
370
435
|
// not the 7th / 21st. (checkFreeTierLimit keeps its `> limit` semantics; we
|
|
371
436
|
// feed it the post-create count. Display in /api/state stays the raw count.)
|
|
437
|
+
//
|
|
438
|
+
// Only the dimension the caller is actually spending gets its real count;
|
|
439
|
+
// the others are zeroed. checkFreeTierLimit weighs all three at once and
|
|
440
|
+
// mirrors billing-api byte-for-byte, so it must not learn about intents —
|
|
441
|
+
// the caller narrows the question instead. Without this, an install already
|
|
442
|
+
// holding 2 projects (created before the project cap was enforced) would
|
|
443
|
+
// trip 'project-limit' on every *goal* create and lose a feature it had:
|
|
444
|
+
// caps are enforced at the moment you spend, never retroactively.
|
|
372
445
|
const existing = computeFreeTierUsage(identity);
|
|
373
|
-
const usage =
|
|
446
|
+
const usage = intent === 'project'
|
|
447
|
+
? { pendingCount: 0, cumulativeCount: 0, projectCount: existing.projectCount + 1 }
|
|
448
|
+
: { ...existing, pendingCount: existing.pendingCount + 1, cumulativeCount: existing.cumulativeCount + 1, projectCount: 0 };
|
|
374
449
|
if (!checkFreeTierLimit(usage).blocked) return { allowed: true, blocked: null };
|
|
375
450
|
const billingEmail = licenseState.email;
|
|
376
451
|
if (!billingEmail) { const r = resolveEntitlement({ isOwner: false, isPaying: false, usage }); noteFreeExhausted(r); return r; }
|
|
@@ -2952,9 +3027,10 @@ const server = createServer(async (req, res) => {
|
|
|
2952
3027
|
// browser; Google bounces back to /oauth/callback (below) with a token.
|
|
2953
3028
|
if (url.pathname === '/api/signin-url' && req.method === 'GET') {
|
|
2954
3029
|
if (!BILLING_API_URL) return json(res, 501, { error: 'sign-in not configured (MANAGER_BILLING_API_URL unset)' });
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
3030
|
+
// Device-flow: no loopback port. buildSigninUrl mints {nonce,verifier},
|
|
3031
|
+
// returns the /connect URL carrying only the PKCE challenge, and kicks off
|
|
3032
|
+
// the background poll that claims the token from the Worker.
|
|
3033
|
+
return json(res, 200, { url: buildSigninUrl() });
|
|
2958
3034
|
}
|
|
2959
3035
|
|
|
2960
3036
|
// GET /oauth/callback?token&email&state | ?error&state — loopback landing for
|
|
@@ -3027,9 +3103,15 @@ const server = createServer(async (req, res) => {
|
|
|
3027
3103
|
if (url.pathname === '/api/projects' && req.method === 'POST') {
|
|
3028
3104
|
let body = '';
|
|
3029
3105
|
req.on('data', (d) => { body += d; });
|
|
3030
|
-
req.on('end', () => {
|
|
3106
|
+
req.on('end', async () => {
|
|
3031
3107
|
try {
|
|
3032
3108
|
const input = JSON.parse(body || '{}');
|
|
3109
|
+
// Billing (docs/BILLING-LAUNCH-PLAN.md §2/§5): a 2nd project is paid.
|
|
3110
|
+
// Same 402 shape as POST /api/tasks so the client has one branch to
|
|
3111
|
+
// handle. Gated here, on create, and nowhere else: projects that
|
|
3112
|
+
// already exist keep working on the free tier.
|
|
3113
|
+
const gate = await requireEntitlementGate(identityFor(req), 'project');
|
|
3114
|
+
if (!gate.allowed) return json(res, 402, { error: 'free-tier limit reached', blocked: gate.blocked });
|
|
3033
3115
|
const base = projects.find((p) => p.id === input.baseProjectId) ?? projects[0];
|
|
3034
3116
|
const nameBase = String(input.name ?? 'New project').trim().slice(0, 80) || 'New project';
|
|
3035
3117
|
let name = nameBase, n = 2;
|
|
@@ -4041,7 +4123,7 @@ server.listen(PORT, '127.0.0.1', () => {
|
|
|
4041
4123
|
const signedIn = existsSync(licenseFile) && !FORCE_SIGNIN;
|
|
4042
4124
|
openUrl = signedIn
|
|
4043
4125
|
? APP_URL
|
|
4044
|
-
:
|
|
4126
|
+
: buildSigninUrl(); // device-flow: also starts the background claim poll
|
|
4045
4127
|
console.log(signedIn
|
|
4046
4128
|
? `[manager] opening your app → ${APP_URL}`
|
|
4047
4129
|
: `[manager] ${FORCE_SIGNIN ? 'switching account' : 'first run'}: opening one-click sign-in → ${openUrl}`);
|
package/package.json
CHANGED