@bongos/core 1.17.1 → 1.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +244 -264
- package/.claude/skills/blocker-review/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +2 -0
- package/.claude/skills/builder-exit/SKILL.md +2 -2
- package/.claude/skills/builder-redteam/SKILL.md +3 -3
- package/.claude/skills/builder-setup/SKILL.md +2 -2
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/.claude/skills/dev/SKILL.md +1 -1
- package/.claude/skills/idea-triage/SKILL.md +2 -2
- package/.claude/skills/ideate/SKILL.md +1 -1
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/.claude/skills/new-project/SKILL.md +3 -0
- package/.claude/skills/paint/SKILL.md +1 -1
- package/GOVERNANCE.md +1 -1
- package/README.md +1 -1
- package/docs/adr/0001-stack-choice-single-vps.md +1 -1
- package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
- package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
- package/docs/adr/0020-security-model-public-stub.md +1 -1
- package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
- package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
- package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
- package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
- package/docs/adr/0030-builders-hall-live-updates.md +1 -1
- package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
- package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
- package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
- package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
- package/docs/adr/0044-per-box-live-game-preview.md +1 -1
- package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
- package/docs/adr/0057-container-cost-ledger.md +3 -3
- package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
- package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
- package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
- package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
- package/docs/adr/0096-require-reward-before-workable.md +9 -9
- package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
- package/docs/adr/0100-two-repo-instance-model.md +1 -1
- package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
- package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
- package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
- package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
- package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
- package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
- package/docs/adr/README.md +9 -6
- package/docs/architecture.md +2 -2
- package/docs/branding-contract.md +5 -4
- package/docs/canonical-permissions.md +1 -1
- package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
- package/docs/file-map.md +18 -17
- package/docs/handoff-template.md +2 -2
- package/docs/modules-contract.md +6 -1
- package/docs/onboarding/diagrams/README.md +7 -7
- package/docs/onboarding/drachmae-and-karma.md +18 -18
- package/docs/onboarding/primer.md +9 -9
- package/docs/recipes/audit-replay.md +2 -2
- package/docs/recipes/builder-box-lifecycle.md +2 -2
- package/docs/recipes/claude-design-loop.md +1 -1
- package/docs/recipes/ops-gotchas.md +1 -1
- package/docs/recipes/private-npm-distribution.md +17 -0
- package/docs/recipes/staging-environment.md +1 -1
- package/docs/recipes/upgrading-the-core.md +55 -0
- package/docs/research/cursor-competitive-analysis.md +1 -1
- package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
- package/modules/discord/module.json +2 -1
- package/modules/economy/achievements.js +9 -1
- package/modules/economy/credits.js +10 -1
- package/modules/hall-ui/public/builders.js +71 -4
- package/modules/hall-ui/public/goal-inbox.js +191 -0
- package/modules/hall-ui/public/hall-widgets.js +14 -1
- package/modules/hall-ui/public/index.html +3 -0
- package/modules/hall-ui/public/new-project-wizard.js +1 -0
- package/modules/hall-ui/public/style.css +43 -0
- package/modules/provisioning/CLAUDE.md +15 -0
- package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
- package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
- package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
- package/modules/provisioning/module.json +17 -0
- package/modules/provisioning/onboard-plan.js +37 -0
- package/modules/provisioning/provisioning.js +539 -0
- package/modules/provisioning/rate-limit.js +35 -0
- package/modules/provisioning/routes/provisioning.js +607 -0
- package/modules/provisioning/tests/provisioning.mjs +232 -0
- package/modules/provisioning/ui/.gitkeep +0 -0
- package/modules/security/secret-scrub.js +10 -0
- package/package-lock.json +2 -2
- package/package.json +7 -2
- package/scripts/discord/README.md +1 -1
- package/scripts/gds/claim.js +98 -48
- package/scripts/gds/claude-materialize.js +30 -9
- package/scripts/gds/cutover-codemod.js +241 -0
- package/scripts/gds/fitness.js +54 -0
- package/scripts/gds/init.js +59 -3
- package/scripts/gds/lint-infra-exec.js +70 -0
- package/scripts/gds/lint-workflow-exec.js +63 -0
- package/scripts/gds/mirror-lineage-check.js +14 -5
- package/scripts/gds/mirror-redact.js +8 -0
- package/scripts/gds/onboard.js +4 -0
- package/scripts/gds/package-core.js +18 -3
- package/scripts/gds/publish-manifest.js +60 -2
- package/scripts/gds/run-routine.js +51 -13
- package/scripts/gds/run-unit-tests.js +4 -0
- package/scripts/gds/update-channel.js +186 -0
- package/scripts/gds/upgrade.js +116 -9
- package/src/bongos/auth.js +14 -1
- package/src/bongos/serve-internal.js +10 -1
- package/src/branding.js +1 -0
- package/src/module-api.js +12 -1
- package/src/module-loader/loader.js +36 -4
- package/src/module-loader/manifest-schema.js +16 -1
- package/tests/branding.mjs +11 -0
- package/tests/claim_error_surface.mjs +107 -0
- package/tests/claude_materialize.mjs +20 -0
- package/tests/consumer_layout_boot.mjs +302 -0
- package/tests/cutover_codemod.mjs +184 -0
- package/tests/device_flow_start.mjs +25 -2
- package/tests/hook_core_resolve.mjs +125 -0
- package/tests/infra_exec_paths.mjs +104 -0
- package/tests/init.mjs +47 -0
- package/tests/mirror_lineage_proof.mjs +14 -0
- package/tests/module_loader.mjs +42 -0
- package/tests/module_manifest.mjs +19 -0
- package/tests/onboard.mjs +13 -0
- package/tests/package_core.mjs +8 -0
- package/tests/provisioning_github_app_routes.mjs +15 -0
- package/tests/publish_manifest.mjs +52 -1
- package/tests/run_routine.mjs +26 -1
- package/tests/run_routine_two_root.mjs +77 -0
- package/tests/scrubber_corpus.mjs +17 -0
- package/tests/update_channel.mjs +186 -0
- package/tests/upgrade.mjs +131 -0
- package/tests/workflow_exec_paths.mjs +98 -0
- package/docs/design/container-exterior-tiles.md +0 -49
- package/docs/design/container-exterior.md +0 -81
- package/docs/design/container-interior.md +0 -77
- package/docs/design/world-map-scroll.md +0 -41
- package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
- package/docs/recipes/cloudbongos-staging-gate.md +0 -139
- package/docs/recipes/cloudbongos-standup.md +0 -111
- package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
- package/docs/recipes/parallel-load-test.md +0 -32
- package/docs/recipes/pixel-art-pipeline.md +0 -411
- package/docs/recipes/staging-data.md +0 -50
- package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
- package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
- package/migrations/instance/110_medusa_planning_seed.sql +0 -70
- package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
- package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
- package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
- package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
- package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
- package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
- package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
- package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
- package/scripts/gds/art-stage.js +0 -152
- package/server.js +0 -212
- package/tests/art_key.mjs +0 -121
- package/tests/day_cycle.mjs +0 -197
- package/tests/snapshot_picker.mjs +0 -233
- package/tests/sprite_facing.mjs +0 -35
- package/tests/terrain_picker.mjs +0 -298
- package/tests/turn_in_place.mjs +0 -280
- package/tests/walkability.mjs +0 -188
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// modules/provisioning/provisioning.js — the provisioning module's domain slice
|
|
2
|
+
// (ADR 0111, task #1942). Pure helpers + data access for instance hosting.
|
|
3
|
+
//
|
|
4
|
+
// DESIGN — the trust boundary (ADR 0111 §2 / ADR 0016). This file NEVER calls a
|
|
5
|
+
// cloud provider (no DigitalOcean, no Cloudflare, no registrar) and holds NO
|
|
6
|
+
// tokens. It only reads/writes the module's own provisioning_* tables and ENQUEUES
|
|
7
|
+
// intents. The control-plane runner (scripts/gds/provision.js, task P3) is the sole
|
|
8
|
+
// token-holder that drains provisioning_intents and does the real infra work.
|
|
9
|
+
//
|
|
10
|
+
// `db` is dependency-INJECTED (passed in as the first arg), exactly like
|
|
11
|
+
// modules/dev-box/boxes.js — so this file has NO core require and is trivially
|
|
12
|
+
// unit-testable with a fake `{ query }`. The route factory passes the doorway pool.
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Constants (mirror the CHECK constraints in provisioning_001_tables.sql)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const INSTANCE_STATES = ['requested', 'provisioning', 'active', 'tearing_down', 'torn_down', 'error'];
|
|
19
|
+
// co-tenant — a process + DB on THIS control-plane checkout (shared box, $0)
|
|
20
|
+
// dedicated — its own DigitalOcean droplet (priced by tier)
|
|
21
|
+
// standalone — its OWN repo checkout + pinned @cloudbongos/core npm dep, co-located on
|
|
22
|
+
// the box (ADR 0108 destination model; owner-hosted, $0). See provision.js.
|
|
23
|
+
const HOSTING_SHAPES = ['co-tenant', 'dedicated', 'standalone'];
|
|
24
|
+
// 'oauth-manifest' (task 2080) is queueable too — the GitHub-App-Manifest onboarding
|
|
25
|
+
// conversion. Today the runner drains it by scanning provisioning_oauth_manifests
|
|
26
|
+
// (drainOAuthManifests), not the intent queue, but it stays a valid action so the
|
|
27
|
+
// widened CHECK (migration provisioning_003) and this list never drift.
|
|
28
|
+
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest'];
|
|
29
|
+
// The states from which a teardown is meaningful (there is something to reclaim).
|
|
30
|
+
// Mirrors dev-box canTransition('deprovision', …): a requested/torn_down/error
|
|
31
|
+
// instance has nothing running to tear down via the happy path.
|
|
32
|
+
const TEARDOWN_FROM = new Set(['provisioning', 'active', 'tearing_down']);
|
|
33
|
+
|
|
34
|
+
// Monthly cost estimate (USD) for a dedicated droplet tier — VISIBILITY only
|
|
35
|
+
// (ADR 0111 §6), never a bill. A small local table so this module needs no
|
|
36
|
+
// sibling-module import; P2 (shared control-plane client extraction) is where a
|
|
37
|
+
// unified SIZE_CATALOG can later back this. Co-tenant is always $0 (it shares an
|
|
38
|
+
// existing box). Unknown tier → 0 (unpriced rather than wrong).
|
|
39
|
+
const TIER_MONTHLY_USD = {
|
|
40
|
+
's-1vcpu-1gb': 6,
|
|
41
|
+
's-1vcpu-2gb': 12,
|
|
42
|
+
's-2vcpu-2gb': 18,
|
|
43
|
+
's-2vcpu-4gb': 24,
|
|
44
|
+
's-4vcpu-8gb': 48,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Pure helpers (no I/O — exported for unit tests)
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
// A DNS/db-safe instance slug: lowercase, starts alnum, 2–40 of [a-z0-9-], no
|
|
52
|
+
// trailing/leading/double dash. This becomes a subdomain label + a DB-name base,
|
|
53
|
+
// so it must be conservative. Returns true/false.
|
|
54
|
+
function isValidSlug(slug) {
|
|
55
|
+
const s = String(slug == null ? '' : slug);
|
|
56
|
+
if (s.length < 2 || s.length > 40) return false;
|
|
57
|
+
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(s)) return false;
|
|
58
|
+
if (s.includes('--')) return false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Monthly USD estimate for a shape+tier. Co-tenant + standalone are free (they share
|
|
63
|
+
// the box / run on the owner's own infra); only a dedicated droplet costs its tier's
|
|
64
|
+
// rate (0 if the tier is unknown/unset). PURE.
|
|
65
|
+
function costEstimateUsd({ hostingShape, tier } = {}) {
|
|
66
|
+
if (hostingShape !== 'dedicated') return 0;
|
|
67
|
+
return TIER_MONTHLY_USD[tier] || 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Normalize an SNI/hostname for an EXACT allow-list match: lowercase, trim, drop a
|
|
71
|
+
// trailing dot and any :port, and reject anything that can't be a real DNS name.
|
|
72
|
+
// Returns '' when the input isn't a plausible hostname (→ the caller denies). Backs
|
|
73
|
+
// the Caddy on-demand-TLS allow-list, so it errs strict: a bare TLD or malformed
|
|
74
|
+
// name is rejected. PURE (no I/O).
|
|
75
|
+
function normalizeHostname(h) {
|
|
76
|
+
let s = String(h == null ? '' : h).trim().toLowerCase();
|
|
77
|
+
if (!s) return '';
|
|
78
|
+
s = s.replace(/:\d+$/, ''); // drop a :port suffix FIRST …
|
|
79
|
+
s = s.replace(/\.$/, ''); // … then an FQDN trailing dot (handles host.:port order)
|
|
80
|
+
if (s.length < 1 || s.length > 253) return '';
|
|
81
|
+
// ≥2 labels (no bare TLD); each label [a-z0-9-], no leading/trailing dash.
|
|
82
|
+
if (!/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(s)) return '';
|
|
83
|
+
return s;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The instance lifecycle states for which an on-demand TLS cert may be minted: a
|
|
87
|
+
// host mid-standup ('provisioning' — Caddy's first handshake can land here, while
|
|
88
|
+
// the snippet is written + reloaded) or live ('active'). NOT 'requested' (nothing
|
|
89
|
+
// stood up yet), 'tearing_down', 'torn_down', or 'error'.
|
|
90
|
+
const CERT_ELIGIBLE_STATES = new Set(['provisioning', 'active']);
|
|
91
|
+
|
|
92
|
+
// Is `hostname` a domain this module ACTUALLY provisioned + is still serving? Backs
|
|
93
|
+
// the Caddy on-demand-TLS `ask` allow-list (ADR 0111 §4): true → Caddy may fetch a
|
|
94
|
+
// Let's Encrypt cert for it; false → refuse (no cert-minting for arbitrary SNIs, the
|
|
95
|
+
// cert-flood guard). Exact match on the normalized host against a cert-eligible
|
|
96
|
+
// instance. NEVER interpolates the host into SQL (parameterized). db-injected.
|
|
97
|
+
async function isProvisionedHostname(db, hostname) {
|
|
98
|
+
const host = normalizeHostname(hostname);
|
|
99
|
+
if (!host) return false;
|
|
100
|
+
const { rows } = await db.query(
|
|
101
|
+
`SELECT status FROM provisioning_instances WHERE lower(domain) = $1 LIMIT 1`,
|
|
102
|
+
[host]
|
|
103
|
+
);
|
|
104
|
+
return !!(rows[0] && CERT_ELIGIBLE_STATES.has(rows[0].status));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// What we surface for an instance — an explicit allow-list projection (the
|
|
108
|
+
// publicBox pattern). There is nothing secret in a row today, but projecting
|
|
109
|
+
// explicitly means a future sensitive column is never leaked by accident.
|
|
110
|
+
function publicInstance(row) {
|
|
111
|
+
if (!row) return { state: 'none' };
|
|
112
|
+
return {
|
|
113
|
+
id: row.id,
|
|
114
|
+
slug: row.slug,
|
|
115
|
+
status: row.status,
|
|
116
|
+
hosting_shape: row.hosting_shape,
|
|
117
|
+
domain: row.domain,
|
|
118
|
+
db_name: row.db_name,
|
|
119
|
+
port: row.port,
|
|
120
|
+
tier: row.tier,
|
|
121
|
+
target_ref: row.target_ref,
|
|
122
|
+
cost_estimate_usd: Number(row.cost_estimate_usd || 0),
|
|
123
|
+
error_note: row.error_note || null,
|
|
124
|
+
created_at: row.created_at,
|
|
125
|
+
updated_at: row.updated_at,
|
|
126
|
+
provisioned_at: row.provisioned_at,
|
|
127
|
+
torn_down_at: row.torn_down_at,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// GitHub App Manifest flow — PURE builders (task 2080, ADR 0133).
|
|
133
|
+
// GitHub has no create-an-OAuth-App API, but the App *Manifest* flow lets the owner
|
|
134
|
+
// click ONE pre-filled link, approve on GitHub, and have GitHub hand us the client
|
|
135
|
+
// creds automatically. These build the manifest + the POST target; the web route
|
|
136
|
+
// mints the state + persists the row, the control-plane runner does the exchange.
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
// The auto GitHub App name. A GitHub App name is GLOBALLY unique across ALL of GitHub and
|
|
140
|
+
// capped at 34 chars, and it is shown prominently on the sign-in consent screen as
|
|
141
|
+
// "<name> by <owner> … will redirect to https://<domain>".
|
|
142
|
+
//
|
|
143
|
+
// PREFER THE INSTANCE DOMAIN (e.g. "demo.cloudbongos.com"): a domain is inherently unique
|
|
144
|
+
// (so it never collides — the whole problem with a bare "demo"), and it reads perfectly on
|
|
145
|
+
// that consent screen because the name IS the destination. GitHub keeps the dots in the
|
|
146
|
+
// DISPLAY name (only the internal slug is transformed), so a domain is a valid name.
|
|
147
|
+
//
|
|
148
|
+
// FALLBACK (no domain, or a domain over 34 chars): brand-qualify the project —
|
|
149
|
+
// "<brand> - <project>" (e.g. "Cloud Bongos - demo") — still far less collision-prone than
|
|
150
|
+
// a bare slug. Either way it is only a DEFAULT the owner can rename on GitHub's screen.
|
|
151
|
+
// PURE.
|
|
152
|
+
function githubAppName({ domain, brand, project } = {}) {
|
|
153
|
+
const MAX = 34;
|
|
154
|
+
// Domain-first: normalizeHostname lowercases + strips port/trailing-dot + validates a
|
|
155
|
+
// real multi-label host (returns '' otherwise). Dots are fine in a GitHub App display name.
|
|
156
|
+
const host = normalizeHostname(domain);
|
|
157
|
+
if (host && host.length <= MAX) return host;
|
|
158
|
+
// Fallback: brand-qualified project. GitHub App display names accept more, but keeping
|
|
159
|
+
// this to alphanumerics/spaces/hyphens stays clean and predictable.
|
|
160
|
+
const clean = (s) => String(s == null ? '' : s)
|
|
161
|
+
.trim()
|
|
162
|
+
.replace(/[^A-Za-z0-9 -]+/g, ' ')
|
|
163
|
+
.replace(/\s+/g, ' ')
|
|
164
|
+
.replace(/^[\s-]+|[\s-]+$/g, '')
|
|
165
|
+
.trim();
|
|
166
|
+
const b = clean(brand);
|
|
167
|
+
const p = clean(project);
|
|
168
|
+
if (b && p) {
|
|
169
|
+
const sep = ' - ';
|
|
170
|
+
const room = MAX - b.length - sep.length;
|
|
171
|
+
// Keep the WHOLE brand (the collision-dodging qualifier) + as much of the project as
|
|
172
|
+
// fits; if the brand alone already fills the budget, the brand is the useful name.
|
|
173
|
+
return room >= 1 ? clean(`${b}${sep}${p.slice(0, room)}`) : b.slice(0, MAX);
|
|
174
|
+
}
|
|
175
|
+
return (b || p || 'Cloud Bongos').slice(0, MAX);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The GitHub App Manifest JSON (docs: apps/sharing-github-apps/registering-a-github-app-
|
|
179
|
+
// from-a-manifest). Two origins matter and MUST differ:
|
|
180
|
+
// • redirect_url — where GitHub sends the browser (with ?code=) AFTER the owner
|
|
181
|
+
// approves. This is the MOTHERSHIP (control plane) callback — the instance itself
|
|
182
|
+
// can't do the token exchange (no tokens, and its own auth may not be live yet).
|
|
183
|
+
// • callback_urls — where "Login with GitHub" on the INSTANCE redirects during real
|
|
184
|
+
// sign-in. Both /api/gds/... (what auth.js actually builds) and the /api/bongos/...
|
|
185
|
+
// alias are registered so sign-in works regardless of which prefix a build emits.
|
|
186
|
+
// Minimal for sign-in: no repo permissions, no webhook (active:false), not public. PURE.
|
|
187
|
+
function buildGithubAppManifest({ instanceName, slug, instanceDomain, mothershipOrigin, brand }) {
|
|
188
|
+
const instOrigin = `https://${instanceDomain}`;
|
|
189
|
+
const redirectUrl = `${String(mothershipOrigin || '').replace(/\/+$/, '')}/api/bongos/provisioning/github-app/callback`;
|
|
190
|
+
return {
|
|
191
|
+
name: githubAppName({ domain: instanceDomain, brand, project: instanceName || slug }),
|
|
192
|
+
url: instOrigin,
|
|
193
|
+
// Sign-in only ever calls GET /user with the user-to-server token — no repo/account
|
|
194
|
+
// permissions are needed, so request none (least privilege).
|
|
195
|
+
default_permissions: {},
|
|
196
|
+
default_events: [],
|
|
197
|
+
// No webhook: active:false. A url is still supplied (GitHub validates the shape) but
|
|
198
|
+
// is never called while inactive; point it at the mothership so it's a real host.
|
|
199
|
+
hook_attributes: { url: `${String(mothershipOrigin || '').replace(/\/+$/, '')}/api/bongos/provisioning/github-app/webhook`, active: false },
|
|
200
|
+
redirect_url: redirectUrl,
|
|
201
|
+
callback_urls: [
|
|
202
|
+
`${instOrigin}/api/gds/auth/web/callback`,
|
|
203
|
+
`${instOrigin}/api/bongos/auth/web/callback`,
|
|
204
|
+
],
|
|
205
|
+
public: false,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The GitHub form-POST target for creating an App from a manifest. Personal account
|
|
210
|
+
// vs org differ only in the URL; the App works for sign-in either way (ADR 0133). The
|
|
211
|
+
// one-time `state` rides the query string (GitHub echoes it back on redirect). PURE.
|
|
212
|
+
function manifestPostUrl({ account = 'personal', orgLogin, state } = {}) {
|
|
213
|
+
const q = state ? `?state=${encodeURIComponent(state)}` : '';
|
|
214
|
+
if (account === 'org' && orgLogin) {
|
|
215
|
+
return `https://github.com/organizations/${encodeURIComponent(orgLogin)}/settings/apps/new${q}`;
|
|
216
|
+
}
|
|
217
|
+
return `https://github.com/settings/apps/new${q}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Data access (db injected; owns only the provisioning_* tables)
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
async function getInstanceById(db, id) {
|
|
225
|
+
const { rows } = await db.query(
|
|
226
|
+
`SELECT * FROM provisioning_instances WHERE id = $1`, [id]
|
|
227
|
+
);
|
|
228
|
+
return rows[0] || null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function getInstanceBySlug(db, slug) {
|
|
232
|
+
const { rows } = await db.query(
|
|
233
|
+
`SELECT * FROM provisioning_instances WHERE slug = $1`, [slug]
|
|
234
|
+
);
|
|
235
|
+
return rows[0] || null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The caller's own instances, newest first.
|
|
239
|
+
async function listInstancesForOwner(db, ownerBuilderId) {
|
|
240
|
+
const { rows } = await db.query(
|
|
241
|
+
`SELECT * FROM provisioning_instances
|
|
242
|
+
WHERE owner_builder_id = $1
|
|
243
|
+
ORDER BY updated_at DESC
|
|
244
|
+
LIMIT 500`,
|
|
245
|
+
[ownerBuilderId]
|
|
246
|
+
);
|
|
247
|
+
return rows;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The full fleet across all owners (Archon), joined to the owner identity.
|
|
251
|
+
async function listAllInstances(db, { status } = {}) {
|
|
252
|
+
const params = [];
|
|
253
|
+
let where = '';
|
|
254
|
+
if (status) { params.push(status); where = `WHERE pi.status = $1`; }
|
|
255
|
+
const { rows } = await db.query(
|
|
256
|
+
`SELECT pi.*, b.github_login, b.display_name, b.rank
|
|
257
|
+
FROM provisioning_instances pi
|
|
258
|
+
JOIN builders b ON b.id = pi.owner_builder_id
|
|
259
|
+
${where}
|
|
260
|
+
ORDER BY pi.updated_at DESC
|
|
261
|
+
LIMIT 1000`,
|
|
262
|
+
params
|
|
263
|
+
);
|
|
264
|
+
return rows;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Create an instance row (status 'requested'). Idempotent on slug: a slug clash
|
|
268
|
+
// re-fetches the existing row and returns { created:false } so the ROUTE can
|
|
269
|
+
// decide (same owner → idempotent re-request; different owner → 409). Never
|
|
270
|
+
// silently reassigns ownership.
|
|
271
|
+
async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier }) {
|
|
272
|
+
const shape = HOSTING_SHAPES.includes(hostingShape) ? hostingShape : 'co-tenant';
|
|
273
|
+
const cost = costEstimateUsd({ hostingShape: shape, tier });
|
|
274
|
+
try {
|
|
275
|
+
const { rows } = await db.query(
|
|
276
|
+
`INSERT INTO provisioning_instances
|
|
277
|
+
(owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd)
|
|
278
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
279
|
+
RETURNING *`,
|
|
280
|
+
[ownerBuilderId, slug, targetRef ?? null, shape, domain ?? null,
|
|
281
|
+
dbName ?? null, port ?? null, tier ?? null, cost]
|
|
282
|
+
);
|
|
283
|
+
return { instance: rows[0], created: true };
|
|
284
|
+
} catch (e) {
|
|
285
|
+
// 23505 = unique_violation on the slug UNIQUE constraint.
|
|
286
|
+
if (e && e.code === '23505') {
|
|
287
|
+
const existing = await getInstanceBySlug(db, slug);
|
|
288
|
+
if (existing) return { instance: existing, created: false };
|
|
289
|
+
}
|
|
290
|
+
throw e;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Advance an instance's lifecycle status (+ optional column patch). Whitelisted
|
|
295
|
+
// patch columns only, so a caller can never write an arbitrary column.
|
|
296
|
+
async function setInstanceStatus(db, id, status, patch = {}) {
|
|
297
|
+
const sets = [`status = $2`, `updated_at = now()`];
|
|
298
|
+
const params = [id, status];
|
|
299
|
+
const COLS = {
|
|
300
|
+
domain: 'domain', db_name: 'db_name', port: 'port', tier: 'tier',
|
|
301
|
+
target_ref: 'target_ref', error_note: 'error_note',
|
|
302
|
+
cost_estimate_usd: 'cost_estimate_usd',
|
|
303
|
+
provisioned_at: 'provisioned_at', torn_down_at: 'torn_down_at',
|
|
304
|
+
};
|
|
305
|
+
for (const [k, col] of Object.entries(COLS)) {
|
|
306
|
+
if (k in patch) { params.push(patch[k]); sets.push(`${col} = $${params.length}`); }
|
|
307
|
+
}
|
|
308
|
+
const { rows } = await db.query(
|
|
309
|
+
`UPDATE provisioning_instances SET ${sets.join(', ')} WHERE id = $1 RETURNING *`,
|
|
310
|
+
params
|
|
311
|
+
);
|
|
312
|
+
return rows[0] || null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Enqueue a provision/teardown intent. Idempotent: the partial unique index
|
|
316
|
+
// ux_provisioning_intents_open makes a second open enqueue collide (23505); we
|
|
317
|
+
// then return the already-open intent with created:false (mirrors boxes.enqueueIntent).
|
|
318
|
+
async function enqueueIntent(db, instanceId, action, requestedBy) {
|
|
319
|
+
try {
|
|
320
|
+
const { rows } = await db.query(
|
|
321
|
+
`INSERT INTO provisioning_intents (instance_id, action, requested_by, state)
|
|
322
|
+
VALUES ($1, $2, $3, 'pending') RETURNING *`,
|
|
323
|
+
[instanceId, action, requestedBy ?? null]
|
|
324
|
+
);
|
|
325
|
+
return { intent: rows[0], created: true };
|
|
326
|
+
} catch (e) {
|
|
327
|
+
if (e && e.code === '23505') {
|
|
328
|
+
const existing = await getOpenIntent(db, instanceId);
|
|
329
|
+
if (existing) return { intent: existing, created: false };
|
|
330
|
+
}
|
|
331
|
+
throw e;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// The instance's currently-open (pending|running) intent, if any.
|
|
336
|
+
async function getOpenIntent(db, instanceId) {
|
|
337
|
+
const { rows } = await db.query(
|
|
338
|
+
`SELECT * FROM provisioning_intents
|
|
339
|
+
WHERE instance_id = $1 AND state IN ('pending', 'running')
|
|
340
|
+
ORDER BY created_at DESC LIMIT 1`,
|
|
341
|
+
[instanceId]
|
|
342
|
+
);
|
|
343
|
+
return rows[0] || null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Atomically claim the oldest pending intent for the runner (task P3): flip it to
|
|
347
|
+
// 'running', bump attempts. FOR UPDATE SKIP LOCKED so concurrent runner ticks never
|
|
348
|
+
// grab the same row. Returns the claimed row or null. (Provided now so the P3 runner
|
|
349
|
+
// reuses this module's data slice rather than re-implementing the queue drain.)
|
|
350
|
+
async function claimNextIntent(db) {
|
|
351
|
+
const { rows } = await db.query(
|
|
352
|
+
`UPDATE provisioning_intents
|
|
353
|
+
SET state = 'running', attempts = attempts + 1, updated_at = now()
|
|
354
|
+
WHERE id = (
|
|
355
|
+
SELECT id FROM provisioning_intents
|
|
356
|
+
WHERE state = 'pending'
|
|
357
|
+
ORDER BY created_at
|
|
358
|
+
FOR UPDATE SKIP LOCKED
|
|
359
|
+
LIMIT 1
|
|
360
|
+
)
|
|
361
|
+
RETURNING *`
|
|
362
|
+
);
|
|
363
|
+
return rows[0] || null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Resolve a claimed intent to its terminal state ('done' | 'error').
|
|
367
|
+
async function resolveIntent(db, id, state, lastError) {
|
|
368
|
+
await db.query(
|
|
369
|
+
`UPDATE provisioning_intents
|
|
370
|
+
SET state = $2, last_error = $3, updated_at = now(), resolved_at = now()
|
|
371
|
+
WHERE id = $1`,
|
|
372
|
+
[id, state, lastError ?? null]
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── GitHub App Manifest attempts (task 2080, ADR 0133) ─────────────────────
|
|
377
|
+
|
|
378
|
+
// Record a fresh manifest onboarding attempt (status 'pending') with its one-time
|
|
379
|
+
// `state` token + TTL. The web route mints the state; the runner never writes here.
|
|
380
|
+
async function createOAuthManifest(db, { instanceId, state, requestedBy, expiresAt }) {
|
|
381
|
+
const { rows } = await db.query(
|
|
382
|
+
`INSERT INTO provisioning_oauth_manifests (instance_id, state, requested_by, expires_at)
|
|
383
|
+
VALUES ($1, $2, $3, $4) RETURNING *`,
|
|
384
|
+
[instanceId, state, requestedBy ?? null, expiresAt]
|
|
385
|
+
);
|
|
386
|
+
return rows[0];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Look up an attempt by its `state` token (the public callback's only key).
|
|
390
|
+
async function getOAuthManifestByState(db, state) {
|
|
391
|
+
if (!state) return null;
|
|
392
|
+
const { rows } = await db.query(
|
|
393
|
+
`SELECT * FROM provisioning_oauth_manifests WHERE state = $1`, [String(state)]
|
|
394
|
+
);
|
|
395
|
+
return rows[0] || null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Attach GitHub's returned conversion `code` and flip 'pending' → 'code_received'.
|
|
399
|
+
// SINGLE-USE + TTL-guarded IN SQL: only a still-pending, un-expired row advances, so a
|
|
400
|
+
// replayed or stale callback is a no-op (returns null). Returns the updated row or null.
|
|
401
|
+
async function attachManifestCode(db, id, code) {
|
|
402
|
+
const { rows } = await db.query(
|
|
403
|
+
`UPDATE provisioning_oauth_manifests
|
|
404
|
+
SET github_code = $2, status = 'code_received', updated_at = now()
|
|
405
|
+
WHERE id = $1 AND status = 'pending' AND expires_at > now()
|
|
406
|
+
RETURNING *`,
|
|
407
|
+
[id, code]
|
|
408
|
+
);
|
|
409
|
+
return rows[0] || null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// The most recent manifest attempt for an instance (any status) — for the web read
|
|
413
|
+
// surface + audit. `statuses` optionally filters (e.g. ['code_received','converting']).
|
|
414
|
+
async function getLatestOAuthManifestForInstance(db, instanceId, { statuses } = {}) {
|
|
415
|
+
const params = [instanceId];
|
|
416
|
+
let filter = '';
|
|
417
|
+
if (Array.isArray(statuses) && statuses.length) {
|
|
418
|
+
params.push(statuses);
|
|
419
|
+
filter = `AND status = ANY($2)`;
|
|
420
|
+
}
|
|
421
|
+
const { rows } = await db.query(
|
|
422
|
+
`SELECT * FROM provisioning_oauth_manifests
|
|
423
|
+
WHERE instance_id = $1 ${filter}
|
|
424
|
+
ORDER BY created_at DESC LIMIT 1`,
|
|
425
|
+
params
|
|
426
|
+
);
|
|
427
|
+
return rows[0] || null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Atomically claim the oldest attempt awaiting conversion for the runner: flip
|
|
431
|
+
// 'code_received' → 'converting', bump attempts. FOR UPDATE SKIP LOCKED so overlapping
|
|
432
|
+
// runner ticks never grab the same row (the claimNextIntent pattern). Null when none.
|
|
433
|
+
//
|
|
434
|
+
// Only claims a row whose INSTANCE is already 'active' — cred placement rewrites
|
|
435
|
+
// web.env + restarts the unit, which only exists once provisioning finished. A code
|
|
436
|
+
// that arrives mid-provision therefore WAITS (no attempt burned, no error) until the
|
|
437
|
+
// instance goes active, instead of failing against a not-yet-created unit. `OF m` locks
|
|
438
|
+
// only the manifest row, never the joined instance row.
|
|
439
|
+
async function claimNextOAuthManifest(db) {
|
|
440
|
+
const { rows } = await db.query(
|
|
441
|
+
`UPDATE provisioning_oauth_manifests
|
|
442
|
+
SET status = 'converting', attempts = attempts + 1, updated_at = now()
|
|
443
|
+
WHERE id = (
|
|
444
|
+
SELECT m.id FROM provisioning_oauth_manifests m
|
|
445
|
+
JOIN provisioning_instances i ON i.id = m.instance_id
|
|
446
|
+
WHERE m.status = 'code_received' AND i.status = 'active'
|
|
447
|
+
ORDER BY m.created_at
|
|
448
|
+
FOR UPDATE OF m SKIP LOCKED
|
|
449
|
+
LIMIT 1
|
|
450
|
+
)
|
|
451
|
+
RETURNING *`
|
|
452
|
+
);
|
|
453
|
+
return rows[0] || null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Resolve a claimed attempt to a terminal-ish status ('done' | 'error' | 'code_received'
|
|
457
|
+
// on a retry). Whitelisted patch columns only (last_error, app_id, app_slug).
|
|
458
|
+
async function resolveOAuthManifest(db, id, status, patch = {}) {
|
|
459
|
+
const sets = [`status = $2`, `updated_at = now()`];
|
|
460
|
+
const params = [id, status];
|
|
461
|
+
const terminal = status === 'done' || status === 'error' || status === 'expired';
|
|
462
|
+
if (terminal) sets.push(`resolved_at = now()`);
|
|
463
|
+
const COLS = { last_error: 'last_error', app_id: 'app_id', app_slug: 'app_slug', github_code: 'github_code' };
|
|
464
|
+
for (const [k, col] of Object.entries(COLS)) {
|
|
465
|
+
if (k in patch) { params.push(patch[k]); sets.push(`${col} = $${params.length}`); }
|
|
466
|
+
}
|
|
467
|
+
const { rows } = await db.query(
|
|
468
|
+
`UPDATE provisioning_oauth_manifests SET ${sets.join(', ')} WHERE id = $1 RETURNING *`,
|
|
469
|
+
params
|
|
470
|
+
);
|
|
471
|
+
return rows[0] || null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Append an audit event to provisioning_events.
|
|
475
|
+
async function recordEvent(db, { instanceId, ownerBuilderId, event, detail, costUsd, actor }) {
|
|
476
|
+
await db.query(
|
|
477
|
+
`INSERT INTO provisioning_events (instance_id, owner_builder_id, event, detail, cost_usd, actor)
|
|
478
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
479
|
+
[instanceId, ownerBuilderId ?? null, event, detail ?? null, costUsd ?? null, actor ?? null]
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// The `provisioning.instanceState` port implementation: given an instance slug,
|
|
484
|
+
// return a small state summary (or null if unknown). Lets OTHER code read an
|
|
485
|
+
// instance's provisioning status via seams.resolveOptional without importing this
|
|
486
|
+
// module (the box.hasEverConnected precedent).
|
|
487
|
+
async function instanceStateForSlug(db, slug) {
|
|
488
|
+
const row = await getInstanceBySlug(db, slug);
|
|
489
|
+
if (!row) return null;
|
|
490
|
+
return {
|
|
491
|
+
slug: row.slug,
|
|
492
|
+
status: row.status,
|
|
493
|
+
hosting_shape: row.hosting_shape,
|
|
494
|
+
domain: row.domain || null,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Fleet cost ledger (Archon): per-instance monthly estimate + a total, over the
|
|
499
|
+
// instances that still cost something (not torn_down). VISIBILITY only (ADR 0111 §6).
|
|
500
|
+
async function costLedger(db, { limit = 500 } = {}) {
|
|
501
|
+
const { rows } = await db.query(
|
|
502
|
+
`SELECT pi.id, pi.slug, pi.hosting_shape, pi.tier, pi.status,
|
|
503
|
+
pi.cost_estimate_usd, b.github_login
|
|
504
|
+
FROM provisioning_instances pi
|
|
505
|
+
JOIN builders b ON b.id = pi.owner_builder_id
|
|
506
|
+
WHERE pi.status <> 'torn_down'
|
|
507
|
+
ORDER BY pi.cost_estimate_usd DESC, pi.updated_at DESC
|
|
508
|
+
LIMIT $1`,
|
|
509
|
+
[Math.min(Math.max(Number(limit) || 500, 1), 2000)]
|
|
510
|
+
);
|
|
511
|
+
const totalMonthlyUsd = rows.reduce((a, r) => a + Number(r.cost_estimate_usd || 0), 0);
|
|
512
|
+
return {
|
|
513
|
+
instances: rows.map((r) => ({
|
|
514
|
+
id: r.id, slug: r.slug, owner: r.github_login,
|
|
515
|
+
hosting_shape: r.hosting_shape, tier: r.tier, status: r.status,
|
|
516
|
+
cost_estimate_usd: Number(r.cost_estimate_usd || 0),
|
|
517
|
+
})),
|
|
518
|
+
total_monthly_usd: Number(totalMonthlyUsd.toFixed(4)),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
module.exports = {
|
|
523
|
+
// constants
|
|
524
|
+
INSTANCE_STATES, HOSTING_SHAPES, INTENT_ACTIONS, TEARDOWN_FROM, TIER_MONTHLY_USD,
|
|
525
|
+
CERT_ELIGIBLE_STATES,
|
|
526
|
+
// pure
|
|
527
|
+
isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
|
|
528
|
+
// pure — GitHub App Manifest flow (task 2080)
|
|
529
|
+
githubAppName, buildGithubAppManifest, manifestPostUrl,
|
|
530
|
+
// data access
|
|
531
|
+
isProvisionedHostname,
|
|
532
|
+
getInstanceById, getInstanceBySlug, listInstancesForOwner, listAllInstances,
|
|
533
|
+
createInstance, setInstanceStatus,
|
|
534
|
+
enqueueIntent, getOpenIntent, claimNextIntent, resolveIntent,
|
|
535
|
+
recordEvent, instanceStateForSlug, costLedger,
|
|
536
|
+
// data access — GitHub App Manifest attempts (task 2080)
|
|
537
|
+
createOAuthManifest, getOAuthManifestByState, attachManifestCode,
|
|
538
|
+
getLatestOAuthManifestForInstance, claimNextOAuthManifest, resolveOAuthManifest,
|
|
539
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// modules/provisioning/rate-limit.js — a small per-IP sliding-window rate limiter
|
|
2
|
+
// for the module's PUBLIC tls-check `ask` endpoint (SEC review of task 1945).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS LIVES HERE (not core reuse). Core has publicReadRateLimitMiddleware
|
|
5
|
+
// (src/bongos/middleware/rate-limit.js), but a module may not import it: the one-way
|
|
6
|
+
// rule forbids a deep core require (ADR 0083), and the kernel doorway may not
|
|
7
|
+
// re-export it because rate-limit.js is a non-kernel core file (ADR 0091 §1). So we
|
|
8
|
+
// carry a dedicated limiter, mirroring core's shape. It is deliberately TIGHT: Caddy
|
|
9
|
+
// calls the on-demand-TLS `ask` endpoint at most once per new SNI, so legitimate
|
|
10
|
+
// volume is tiny — a low per-IP cap sheds an enumeration/issuance flood without ever
|
|
11
|
+
// throttling a real TLS handshake.
|
|
12
|
+
//
|
|
13
|
+
// In-memory + per-process (the core limiter's model too). `now` is injectable so the
|
|
14
|
+
// window logic is unit-testable without a real clock.
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
function makeAskRateLimiter({ windowMs = 60_000, max = 60, now = Date.now } = {}) {
|
|
18
|
+
const buckets = new Map(); // ip -> array of recent hit timestamps within the window
|
|
19
|
+
return function askRateLimit(req, res, next) {
|
|
20
|
+
const t = now();
|
|
21
|
+
const ip = req.ip || (req.socket && req.socket.remoteAddress) || 'unknown';
|
|
22
|
+
// Prune this IP's bucket to the window, then decide.
|
|
23
|
+
const hits = (buckets.get(ip) || []).filter((ts) => ts > t - windowMs);
|
|
24
|
+
if (hits.length >= max) {
|
|
25
|
+
const retryAfter = Math.max(1, Math.ceil((hits[0] + windowMs - t) / 1000));
|
|
26
|
+
res.set('Retry-After', String(retryAfter));
|
|
27
|
+
return res.status(429).json({ ok: false, error: 'rate_limited', retry_after_seconds: retryAfter });
|
|
28
|
+
}
|
|
29
|
+
hits.push(t);
|
|
30
|
+
buckets.set(ip, hits);
|
|
31
|
+
return next();
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { makeAskRateLimiter };
|