@solgate/server 0.2.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Solana Allowlist contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,415 @@
1
+ import * as _solgate_core from '@solgate/core';
2
+ import { Campaign, Entry, ModuleRegistry, Requirement, RequirementResult, AllowlistEvent, AllowlistEventType } from '@solgate/core';
3
+ import * as hono_types from 'hono/types';
4
+ import { Context, Hono } from 'hono';
5
+
6
+ interface SocialLink {
7
+ campaignId: string;
8
+ provider: string;
9
+ providerUserId: string;
10
+ wallet: string;
11
+ handle?: string;
12
+ meta?: Record<string, unknown>;
13
+ createdAt: number;
14
+ }
15
+ interface Webhook {
16
+ id: string;
17
+ url: string;
18
+ secret: string;
19
+ events: string[];
20
+ campaignId?: string;
21
+ active: boolean;
22
+ createdAt: number;
23
+ }
24
+ interface WebhookDelivery {
25
+ id: string;
26
+ webhookId: string;
27
+ eventId: string;
28
+ status: number;
29
+ attempts: number;
30
+ lastError?: string;
31
+ createdAt: number;
32
+ }
33
+ /** Immutable eligibility snapshot taken before a mint. */
34
+ interface Snapshot {
35
+ id: string;
36
+ campaignId: string;
37
+ createdAt: number;
38
+ count: number;
39
+ totalAllocation: number;
40
+ merkle?: {
41
+ scheme: string;
42
+ root: string;
43
+ };
44
+ /** wallet → { allocation, rank, proof } */
45
+ entries: Record<string, {
46
+ allocation: number;
47
+ rank: number;
48
+ proof?: string[];
49
+ }>;
50
+ }
51
+ interface ApiKey {
52
+ id: string;
53
+ hash: string;
54
+ label: string;
55
+ scopes: ("admin" | "read")[];
56
+ createdAt: number;
57
+ }
58
+ /**
59
+ * Storage adapter. Every method is async so the same interface works for
60
+ * in-memory, SQLite, Postgres, D1, KV, etc. Keep it small on purpose.
61
+ */
62
+ interface Storage {
63
+ getCampaign(id: string): Promise<Campaign | null>;
64
+ listCampaigns(): Promise<Campaign[]>;
65
+ putCampaign(c: Campaign): Promise<void>;
66
+ deleteCampaign(id: string): Promise<void>;
67
+ getEntry(campaignId: string, wallet: string): Promise<Entry | null>;
68
+ putEntry(e: Entry): Promise<void>;
69
+ listEntries(campaignId: string, opts?: {
70
+ eligibleOnly?: boolean;
71
+ offset?: number;
72
+ limit?: number;
73
+ }): Promise<Entry[]>;
74
+ countEntries(campaignId: string, opts?: {
75
+ eligibleOnly?: boolean;
76
+ }): Promise<number>;
77
+ findEntryByReferralCode(campaignId: string, code: string): Promise<Entry | null>;
78
+ setTemp(key: string, value: string, ttlSeconds: number): Promise<void>;
79
+ getTemp(key: string): Promise<string | null>;
80
+ deleteTemp(key: string): Promise<void>;
81
+ /** ATOMIC increment with TTL (rate limits, attempt counters, referral caps). Must be safe under concurrency. */
82
+ incrTemp(key: string, ttlSeconds: number): Promise<number>;
83
+ getSocialLink(campaignId: string, provider: string, providerUserId: string): Promise<SocialLink | null>;
84
+ /**
85
+ * ATOMIC "insert if absent". Returns `{ ok: true }` when this wallet now owns the identity
86
+ * (fresh insert or already owned by the same wallet), otherwise `{ ok: false, owner }`.
87
+ * The database's unique constraint — not application sequencing — enforces one-identity-one-wallet.
88
+ */
89
+ claimSocialLink(link: SocialLink): Promise<{
90
+ ok: true;
91
+ } | {
92
+ ok: false;
93
+ owner: string;
94
+ }>;
95
+ listSocialLinksForWallet(campaignId: string, wallet: string): Promise<SocialLink[]>;
96
+ putSnapshot(s: Snapshot): Promise<void>;
97
+ getSnapshot(campaignId: string, id?: string): Promise<Snapshot | null>;
98
+ listSnapshots(campaignId: string): Promise<Omit<Snapshot, "entries">[]>;
99
+ listWebhooks(): Promise<Webhook[]>;
100
+ putWebhook(w: Webhook): Promise<void>;
101
+ deleteWebhook(id: string): Promise<void>;
102
+ putDelivery(d: WebhookDelivery): Promise<void>;
103
+ listApiKeys(): Promise<ApiKey[]>;
104
+ putApiKey(k: ApiKey): Promise<void>;
105
+ deleteApiKey(id: string): Promise<void>;
106
+ }
107
+
108
+ interface OAuthClient {
109
+ clientId: string;
110
+ clientSecret: string;
111
+ }
112
+ interface ServerConfig {
113
+ storage: Storage;
114
+ /** Public origin of this API, e.g. https://api.myproject.xyz — used for OAuth callbacks. */
115
+ baseUrl: string;
116
+ /** Domain shown in the sign-in message (defaults to hostname of baseUrl). */
117
+ domain?: string;
118
+ /** Origins allowed to embed the widget. */
119
+ corsOrigins?: string[] | "*";
120
+ /** 32+ byte secret for signing sessions. */
121
+ sessionSecret: string;
122
+ sessionTtlSeconds?: number;
123
+ /** Bootstrap admin key. Additional keys can be created via API. */
124
+ adminApiKey?: string;
125
+ /**
126
+ * Public eligibility lookup (`GET /campaigns/:id/eligibility/:wallet`) exposure:
127
+ * - "minimal" (default): anyone can read { eligible, allocation, merkle proof } — what a mint page needs.
128
+ * - "full": also points and rank without a key.
129
+ * - "protected": requires a read API key for everything.
130
+ */
131
+ eligibilityLookup?: "minimal" | "full" | "protected";
132
+ /**
133
+ * Which header carries the real client IP. Only trust headers your reverse
134
+ * proxy overwrites, otherwise rate limits can be bypassed by spoofing.
135
+ * - "none" (default): use the socket address supplied via `getClientIp`, else no per-IP limiting
136
+ * - "cloudflare": cf-connecting-ip
137
+ * - "x-forwarded-for": first hop of x-forwarded-for (nginx, Vercel, Railway, Fly)
138
+ */
139
+ trustProxy?: "none" | "cloudflare" | "x-forwarded-for";
140
+ /** Runtime-specific socket address resolver (e.g. @hono/node-server's getConnInfo). */
141
+ getClientIp?: (req: Request) => string | undefined;
142
+ /** Origins the OAuth flow may redirect back to, in addition to baseUrl and corsOrigins. */
143
+ allowedReturnOrigins?: string[];
144
+ /** Allow http:// webhook targets (development only). */
145
+ allowInsecureWebhooks?: boolean;
146
+ solana: {
147
+ rpcUrl: string;
148
+ /** DAS-compatible endpoint (Helius, Triton, QuickNode…) for NFT lookups. Defaults to rpcUrl. */
149
+ dasUrl?: string;
150
+ };
151
+ oauth?: {
152
+ x?: OAuthClient;
153
+ discord?: OAuthClient & {
154
+ botToken?: string;
155
+ };
156
+ google?: OAuthClient;
157
+ };
158
+ telegram?: {
159
+ botToken: string;
160
+ botUsername: string;
161
+ };
162
+ captcha?: {
163
+ turnstileSecret?: string;
164
+ hcaptchaSecret?: string;
165
+ recaptchaSecret?: string;
166
+ };
167
+ /** Custom modules & verifiers. */
168
+ registry?: ModuleRegistry;
169
+ verifiers?: Verifier[];
170
+ rateLimit?: {
171
+ windowSeconds: number;
172
+ max: number;
173
+ };
174
+ }
175
+ declare function normalizeConfig(cfg: ServerConfig): {
176
+ domain: string;
177
+ sessionTtlSeconds: number;
178
+ corsOrigins: string[] | "*";
179
+ eligibilityLookup: "minimal" | "full" | "protected";
180
+ trustProxy: "none" | "cloudflare" | "x-forwarded-for";
181
+ rateLimit: {
182
+ windowSeconds: number;
183
+ max: number;
184
+ };
185
+ solana: {
186
+ dasUrl: string;
187
+ rpcUrl: string;
188
+ };
189
+ storage: Storage;
190
+ /** Public origin of this API, e.g. https://api.myproject.xyz — used for OAuth callbacks. */
191
+ baseUrl: string;
192
+ /** 32+ byte secret for signing sessions. */
193
+ sessionSecret: string;
194
+ /** Bootstrap admin key. Additional keys can be created via API. */
195
+ adminApiKey?: string;
196
+ /** Runtime-specific socket address resolver (e.g. @hono/node-server's getConnInfo). */
197
+ getClientIp?: (req: Request) => string | undefined;
198
+ /** Origins the OAuth flow may redirect back to, in addition to baseUrl and corsOrigins. */
199
+ allowedReturnOrigins?: string[];
200
+ /** Allow http:// webhook targets (development only). */
201
+ allowInsecureWebhooks?: boolean;
202
+ oauth?: {
203
+ x?: OAuthClient;
204
+ discord?: OAuthClient & {
205
+ botToken?: string;
206
+ };
207
+ google?: OAuthClient;
208
+ };
209
+ telegram?: {
210
+ botToken: string;
211
+ botUsername: string;
212
+ };
213
+ captcha?: {
214
+ turnstileSecret?: string;
215
+ hcaptchaSecret?: string;
216
+ recaptchaSecret?: string;
217
+ };
218
+ /** Custom modules & verifiers. */
219
+ registry?: ModuleRegistry;
220
+ verifiers?: Verifier[];
221
+ };
222
+ type NormalizedConfig = ReturnType<typeof normalizeConfig>;
223
+
224
+ interface VerifyContext<TConfig = unknown, TInput = unknown> {
225
+ campaign: Campaign;
226
+ requirement: Requirement<TConfig>;
227
+ config: TConfig;
228
+ wallet: string;
229
+ entry: Entry;
230
+ /** Client-supplied payload (quiz answers, captcha token, referral code, oauth profile…). */
231
+ input: TInput;
232
+ storage: Storage;
233
+ cfg: NormalizedConfig;
234
+ /** Request metadata for rate limiting / audit. */
235
+ ip?: string;
236
+ }
237
+ interface Verifier<TConfig = unknown, TInput = unknown> {
238
+ moduleId: string;
239
+ verify(ctx: VerifyContext<TConfig, TInput>): Promise<RequirementResult>;
240
+ /** Optional: return public config safe for the browser (e.g. strip quiz answers). */
241
+ publicConfig?(config: TConfig): unknown;
242
+ }
243
+ declare const pass: (key: string, module: string, evidence?: Record<string, unknown>) => RequirementResult;
244
+ declare const fail: (key: string, module: string, reason: string, evidence?: Record<string, unknown>) => RequirementResult;
245
+ /**
246
+ * Throw this when an upstream dependency (RPC, provider API) failed. The
247
+ * service leaves the entry untouched and returns HTTP 503 to the client, so
248
+ * the user can retry — instead of persisting a false "failed" result.
249
+ */
250
+ declare class VerificationUnavailable extends Error {
251
+ readonly code = "verification_unavailable";
252
+ }
253
+
254
+ /**
255
+ * Webhook dispatcher. Payloads are signed with HMAC-SHA256 over
256
+ * `${timestamp}.${body}` in the `X-Allowlist-Signature` header
257
+ * ("t=<ts>,v1=<hex>"), so receivers can verify + reject replays.
258
+ */
259
+ declare class EventBus {
260
+ private storage;
261
+ private waitUntil?;
262
+ private listeners;
263
+ constructor(storage: Storage, waitUntil?: ((p: Promise<unknown>) => void) | undefined);
264
+ on(fn: (e: AllowlistEvent) => void | Promise<void>): () => ((e: AllowlistEvent) => void | Promise<void>)[];
265
+ emit<T>(type: AllowlistEventType, campaignId: string, data: T, wallet?: string): Promise<AllowlistEvent<T>>;
266
+ private dispatch;
267
+ deliver(webhookId: string, url: string, secret: string, event: AllowlistEvent, maxAttempts?: number): Promise<void>;
268
+ }
269
+ /** Helper for receivers (Next.js API route, Express, etc). */
270
+ declare function verifyWebhookSignature(secret: string, header: string, body: string, toleranceSeconds?: number): boolean;
271
+
272
+ declare class AllowlistError extends Error {
273
+ status: number;
274
+ code: string;
275
+ constructor(status: number, message: string, code?: string);
276
+ }
277
+ /** Framework-agnostic business logic. Routes are thin wrappers over this. */
278
+ declare class AllowlistService {
279
+ cfg: NormalizedConfig;
280
+ registry: ModuleRegistry;
281
+ verifiers: Map<string, Verifier<unknown, unknown>>;
282
+ events: EventBus;
283
+ constructor(cfg: NormalizedConfig, waitUntil?: (p: Promise<unknown>) => void);
284
+ getCampaign(id: string): Promise<Campaign>;
285
+ saveCampaign(input: unknown): Promise<Campaign>;
286
+ /**
287
+ * Campaign as seen by the browser. Requirement config is PRIVATE BY DEFAULT:
288
+ * a module must opt in via `publicConfig` (on its definition, or on the
289
+ * verifier) to expose anything. Custom modules that forget get `{}`, not
290
+ * their secrets.
291
+ */
292
+ publicCampaign(c: Campaign): {
293
+ requirements: {
294
+ config: {};
295
+ category: "input" | "onchain" | "social" | "custom";
296
+ label: string;
297
+ key: string;
298
+ module: string;
299
+ required?: boolean;
300
+ points?: number;
301
+ title?: string;
302
+ description?: string;
303
+ }[];
304
+ minPoints: number;
305
+ allocation: {
306
+ flat: number;
307
+ mode: "flat" | "tiered" | "per-requirement";
308
+ tiers: {
309
+ minPoints: number;
310
+ allocation: number;
311
+ label?: string | undefined;
312
+ }[];
313
+ perRequirement: Record<string, number>;
314
+ maxPerWallet?: number | undefined;
315
+ totalSupply?: number | undefined;
316
+ };
317
+ type: "custom" | "nft-mint" | "token-launch" | "presale" | "community";
318
+ id: string;
319
+ name: string;
320
+ cluster: "mainnet-beta" | "devnet" | "testnet";
321
+ merkle: {
322
+ enabled: boolean;
323
+ scheme: "candy-guard" | "sha256-sorted";
324
+ };
325
+ metadata: Record<string, unknown>;
326
+ description?: string | undefined | undefined;
327
+ startsAt?: string | undefined | undefined;
328
+ endsAt?: string | undefined | undefined;
329
+ maxEntries?: number | undefined | undefined;
330
+ createdAt?: string | undefined | undefined;
331
+ updatedAt?: string | undefined | undefined;
332
+ };
333
+ getOrCreateEntry(c: Campaign, wallet: string): Promise<Entry>;
334
+ private uniqueReferralCode;
335
+ /** Run one requirement's verifier and persist the result. */
336
+ verifyRequirement(c: Campaign, wallet: string, key: string, input: unknown, ip?: string): Promise<{
337
+ entry: Entry;
338
+ result: RequirementResult;
339
+ }>;
340
+ /** Re-run all recheckable (on-chain) requirements for every entry — e.g. snapshot before mint. */
341
+ recheckCampaign(c: Campaign, onlyWallets?: string[]): Promise<{
342
+ checked: number;
343
+ changed: number;
344
+ unavailable: number;
345
+ }>;
346
+ /**
347
+ * Freeze the current eligible set (with caps applied) into an immutable
348
+ * snapshot: root, per-wallet allocation/rank/proof. Serve mint-time lookups
349
+ * from this so the root can't drift while people are minting.
350
+ */
351
+ createSnapshot(c: Campaign): Promise<Snapshot>;
352
+ /** Entries with campaign-wide caps applied (ranked FCFS). */
353
+ finalEntries(c: Campaign): Promise<Entry[]>;
354
+ /** Admin override for a single requirement (approve social task, manual pass/fail). */
355
+ overrideRequirement(c: Campaign, wallet: string, key: string, passed: boolean, note?: string): Promise<Entry>;
356
+ }
357
+
358
+ type AppEnv = {
359
+ Variables: {
360
+ session: {
361
+ wallet: string;
362
+ campaignId: string;
363
+ };
364
+ apiKeyScopes: string[];
365
+ };
366
+ };
367
+ declare function createAllowlistApp(config: ServerConfig, opts?: {
368
+ waitUntil?: (p: Promise<unknown>) => void;
369
+ getClientIp?: (c: Context) => string | undefined;
370
+ }): {
371
+ app: Hono<AppEnv, hono_types.BlankSchema, "/">;
372
+ service: AllowlistService;
373
+ config: {
374
+ domain: string;
375
+ sessionTtlSeconds: number;
376
+ corsOrigins: string[] | "*";
377
+ eligibilityLookup: "minimal" | "full" | "protected";
378
+ trustProxy: "none" | "cloudflare" | "x-forwarded-for";
379
+ rateLimit: {
380
+ windowSeconds: number;
381
+ max: number;
382
+ };
383
+ solana: {
384
+ dasUrl: string;
385
+ rpcUrl: string;
386
+ };
387
+ storage: Storage;
388
+ baseUrl: string;
389
+ sessionSecret: string;
390
+ adminApiKey?: string;
391
+ getClientIp?: (req: Request) => string | undefined;
392
+ allowedReturnOrigins?: string[];
393
+ allowInsecureWebhooks?: boolean;
394
+ oauth?: {
395
+ x?: OAuthClient;
396
+ discord?: OAuthClient & {
397
+ botToken?: string;
398
+ };
399
+ google?: OAuthClient;
400
+ };
401
+ telegram?: {
402
+ botToken: string;
403
+ botUsername: string;
404
+ };
405
+ captcha?: {
406
+ turnstileSecret?: string;
407
+ hcaptchaSecret?: string;
408
+ recaptchaSecret?: string;
409
+ };
410
+ registry?: _solgate_core.ModuleRegistry;
411
+ verifiers?: Verifier[];
412
+ };
413
+ };
414
+
415
+ export { type AppEnv as A, EventBus as E, type NormalizedConfig as N, type ServerConfig as S, type Verifier as V, type Webhook as W, type Storage as a, type SocialLink as b, type WebhookDelivery as c, type ApiKey as d, type Snapshot as e, AllowlistError as f, AllowlistService as g, VerificationUnavailable as h, type VerifyContext as i, createAllowlistApp as j, fail as k, pass as p, verifyWebhookSignature as v };
@@ -0,0 +1,15 @@
1
+ var __getOwnPropNames = Object.getOwnPropertyNames;
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
+ }) : x)(function(x) {
5
+ if (typeof require !== "undefined") return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+ var __commonJS = (cb, mod) => function __require2() {
9
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
10
+ };
11
+
12
+ export {
13
+ __require,
14
+ __commonJS
15
+ };