@profullstack/affiliates 0.0.0-stage → 0.1.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 Profullstack, Inc. (moshcoder)
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.
package/README.md CHANGED
@@ -1,3 +1,164 @@
1
- # Temporary Holding Version
1
+ # @profullstack/affiliates
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Scarce invite codes plus open affiliate links, for every Profullstack property.
4
+
5
+ - **Invite codes** are scarce on purpose. Each user gets a small quota (default 3). Each code
6
+ works once, expires (default 30 days), cannot be redeemed by the person who made it, and records
7
+ who invited whom. Codes look like `INV-7K3M-QX9D-2F8H` (60 random bits, Crockford base32) and are
8
+ stored only as sha256 hashes, so a database leak does not leak working codes.
9
+ - **Affiliate codes** are open: one stable public code per user, unlimited use, first-touch
10
+ attribution that is never overwritten.
11
+ - **Commission math** is not reimplemented here. It comes from
12
+ [`@profullstack/referrals`](https://www.npmjs.com/package/@profullstack/referrals)
13
+ (`calculateReferral`, `DEFAULT_SPLIT` = 60% affiliate, 20% customer discount), and its
14
+ `buildReferralUrl` / `extractCode` are re-exported.
15
+
16
+ Stores: in-memory, SQLite (`bun:sqlite`, `node:sqlite`, `better-sqlite3`) and Postgres (anything
17
+ with a node-postgres style `query`). SQL stores create their own tables (prefix `aff_`).
18
+
19
+ ```sh
20
+ npm i @profullstack/affiliates
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ```ts
26
+ import { Database } from 'bun:sqlite'; // or: import { DatabaseSync } from 'node:sqlite'
27
+ import { Affiliates, sqliteStore, buildReferralUrl, extractCode } from '@profullstack/affiliates';
28
+
29
+ const aff = new Affiliates(sqliteStore(new Database('app.db')));
30
+
31
+ // invites: scarce, single use
32
+ const invite = await aff.createInvite('user_alice', 'for Carol');
33
+ invite.code; // 'INV-7K3M-QX9D-2F8H' (shown once, only the hash is stored)
34
+ await aff.quota('user_alice'); // { total: 3, used: 1, left: 2 }
35
+ await aff.redeemInvite(invite.code, 'user_carol'); // { ok: true, inviterId: 'user_alice', inviteId: '...' }
36
+ await aff.redeemInvite(invite.code, 'user_dave'); // { ok: false, reason: 'used' }
37
+ await aff.invitedBy('user_carol'); // 'user_alice'
38
+ await aff.setQuota('user_alice', 10); // admin: hand out more
39
+
40
+ // affiliate links: open, unlimited, first touch wins
41
+ const code = await aff.affiliateCode('user_alice'); // 'E6KETYBK' (stable)
42
+ const link = buildReferralUrl('https://example.com/signup', code); // ...?ref=E6KETYBK
43
+ await aff.attribute(extractCode(link)!, 'user_erin'); // { ok: true, affiliateId: 'user_alice' }
44
+ aff.commission(10_000); // { discountCents: 2000, commissionCents: 6000 }
45
+ ```
46
+
47
+ Postgres:
48
+
49
+ ```ts
50
+ import pg from 'pg';
51
+ import { Affiliates, postgresStore } from '@profullstack/affiliates';
52
+ const aff = new Affiliates(postgresStore(new pg.Pool({ connectionString: process.env.DATABASE_URL })));
53
+ ```
54
+
55
+ ## Invite-only signup in 5 lines
56
+
57
+ ```ts
58
+ const aff = new Affiliates(sqliteStore(db));
59
+ const r = await aff.redeemInvite(body.inviteCode, newUserId); // in your signup handler, before creating the account
60
+ if (!r.ok) return new Response(`Invite ${r.reason}`, { status: 403 });
61
+ await createUser(newUserId, { invitedBy: r.inviterId });
62
+ // later, on the user's settings page: const { code } = await aff.createInvite(userId);
63
+ ```
64
+
65
+ Allocate the user id before redeeming (or redeem inside the same transaction you create the user in)
66
+ so a failed account creation does not silently burn the invite. If it does, `setQuota` the inviter
67
+ one more.
68
+
69
+ ## API
70
+
71
+ ```ts
72
+ export type Invite = {
73
+ code: string; // plaintext, only on create
74
+ id: string;
75
+ inviterId: string;
76
+ createdAt: number; // ms
77
+ expiresAt: number | null;
78
+ usedBy: string | null;
79
+ usedAt: number | null;
80
+ note: string;
81
+ };
82
+ export type RedeemInviteResult =
83
+ | { ok: true; inviterId: string; inviteId: string }
84
+ | { ok: false; reason: 'invalid' | 'expired' | 'used' | 'self' | 'revoked' };
85
+
86
+ export interface AffiliateStore { /* see src/store.ts: quota, invite, claim, attribution primitives */ }
87
+ export function memoryStore(): AffiliateStore;
88
+ export function sqliteStore(db: SqliteLike, opts?: { prefix?: string }): AffiliateStore;
89
+ export function postgresStore(
90
+ pool: { query(sql: string, params?: unknown[]): Promise<{ rows: any[] }> },
91
+ opts?: { prefix?: string },
92
+ ): AffiliateStore;
93
+
94
+ export class Affiliates {
95
+ constructor(store: AffiliateStore, opts?: {
96
+ now?: () => number; // clock, default Date.now
97
+ defaultQuota?: number; // default 3
98
+ inviteTtlMs?: number | null; // default 30 days, null = never expires
99
+ split?: ReferralSplit; // default DEFAULT_SPLIT from @profullstack/referrals
100
+ });
101
+ quota(userId: string): Promise<{ total: number; used: number; left: number }>;
102
+ setQuota(userId: string, total: number): Promise<void>;
103
+ createInvite(inviterId: string, note?: string): Promise<Invite>; // throws Error('no invites left')
104
+ redeemInvite(code: string, newUserId: string): Promise<RedeemInviteResult>;
105
+ invitedBy(userId: string): Promise<string | null>;
106
+ invitees(inviterId: string): Promise<{ userId: string; at: number }[]>;
107
+ invites(inviterId: string): Promise<Omit<Invite, 'code'>[]>; // newest first; also carries revokedAt
108
+ revokeInvite(inviteId: string): Promise<void>;
109
+ affiliateCode(userId: string): Promise<string>;
110
+ attribute(code: string, newUserId: string): Promise<{ ok: boolean; affiliateId?: string }>;
111
+ affiliateOf(userId: string): Promise<string | null>;
112
+ commission(amountCents: number): { discountCents: number; commissionCents: number };
113
+ }
114
+ export { buildReferralUrl, extractCode, calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
115
+ export function normalizeCode(input: string): string;
116
+ ```
117
+
118
+ Semantics worth knowing:
119
+
120
+ - **Quota** counts every invite a user created except ones revoked before use, so revoking an unused
121
+ invite refunds the slot. `setQuota` sets the total, not an increment.
122
+ - **Single use** is enforced in the store with `UPDATE ... WHERE used_by IS NULL AND revoked_at IS NULL`;
123
+ under any number of concurrent redeems exactly one wins and the rest get `used`.
124
+ - **Quota under concurrency**: SQLite serializes writers, and on Postgres the check and insert run in
125
+ a small `aff_insert_invite()` function under a per-inviter advisory lock, so parallel
126
+ `createInvite` calls cannot overshoot.
127
+ - **Redeem order**: `invalid`, `revoked`, `used`, `self`, `expired`. A self-redeem does not burn the
128
+ invite. If a user redeems two different invites, `invitedBy` returns the first inviter.
129
+ - **normalizeCode** uppercases, drops spaces and punctuation, and maps the Crockford look-alikes
130
+ `O` to `0` and `I`/`L` to `1`. Invite codes come back as `INV-XXXX-XXXX-XXXX` (the `INV` prefix is
131
+ optional on input), so `inv 7k3m qx9d 2f8h` redeems.
132
+ - **Affiliate attribution** is first touch: a second `attribute` for the same user returns
133
+ `{ ok: false, affiliateId: <the original> }`. Users cannot attribute themselves.
134
+
135
+ Tables (prefix configurable): `aff_invites`, `aff_quotas`, `aff_codes`, `aff_attributions`.
136
+
137
+ ## CLI
138
+
139
+ ```sh
140
+ npx @profullstack/affiliates --db app.db invite create user_alice --note "for Carol"
141
+ affiliates --db app.db invite list user_alice
142
+ affiliates --db app.db invite revoke <inviteId>
143
+ affiliates --db app.db invite redeem INV-7K3M-QX9D-2F8H user_carol
144
+ affiliates --db app.db quota set user_alice 10
145
+ affiliates --db app.db quota get user_alice
146
+ affiliates --db app.db invitedby user_carol
147
+ affiliates --db app.db invitees user_alice
148
+ affiliates --db app.db code user_alice
149
+ affiliates --db app.db --json commission 10000
150
+ ```
151
+
152
+ Works on a SQLite file through `node:sqlite` under Node (22.13+) and `bun:sqlite` under Bun. Every
153
+ command takes `--json`. Other options: `--prefix`, `--default-quota`, `--ttl-days` (0 = no expiry).
154
+ `--db` defaults to `$AFFILIATES_DB`, then `./affiliates.db`.
155
+
156
+ ## Development
157
+
158
+ ```sh
159
+ npm test # node --experimental-strip-types --test (memory + node:sqlite)
160
+ AFFILIATES_TEST_PG_URL=postgres://postgres:x@127.0.0.1:55432/postgres npm test # adds real Postgres
161
+ npm run build # tsc to dist/
162
+ ```
163
+
164
+ MIT, Profullstack, Inc.
@@ -0,0 +1,80 @@
1
+ import { type ReferralSplit } from '@profullstack/referrals';
2
+ import type { AffiliateStore } from './store.js';
3
+ export type Invite = {
4
+ /** Plaintext code. Only returned by createInvite; never stored. */
5
+ code: string;
6
+ id: string;
7
+ inviterId: string;
8
+ createdAt: number;
9
+ expiresAt: number | null;
10
+ usedBy: string | null;
11
+ usedAt: number | null;
12
+ note: string;
13
+ };
14
+ export type RedeemInviteResult = {
15
+ ok: true;
16
+ inviterId: string;
17
+ inviteId: string;
18
+ } | {
19
+ ok: false;
20
+ reason: 'invalid' | 'expired' | 'used' | 'self' | 'revoked';
21
+ };
22
+ export interface AffiliatesOptions {
23
+ /** Clock in ms. Default Date.now. */
24
+ now?: () => number;
25
+ /** Invites each user may create unless setQuota says otherwise. Default 3. */
26
+ defaultQuota?: number;
27
+ /** Invite lifetime in ms; null for no expiry. Default 30 days. */
28
+ inviteTtlMs?: number | null;
29
+ /** Commission split passed to @profullstack/referrals. Default DEFAULT_SPLIT (60% affiliate, 20% customer). */
30
+ split?: ReferralSplit;
31
+ }
32
+ /** Scarce invite codes plus open affiliate links over any AffiliateStore. */
33
+ export declare class Affiliates {
34
+ readonly store: AffiliateStore;
35
+ readonly now: () => number;
36
+ readonly defaultQuota: number;
37
+ readonly inviteTtlMs: number | null;
38
+ readonly split: ReferralSplit;
39
+ constructor(store: AffiliateStore, opts?: AffiliatesOptions);
40
+ private total;
41
+ /** Invites a user may create, has created (revoked-before-use ones are refunded), and has left. */
42
+ quota(userId: string): Promise<{
43
+ total: number;
44
+ used: number;
45
+ left: number;
46
+ }>;
47
+ /** Admin: set a user's invite allowance (total, not additional). */
48
+ setQuota(userId: string, total: number): Promise<void>;
49
+ /** Mint one invite. Throws Error('no invites left') when the quota is spent. */
50
+ createInvite(inviterId: string, note?: string): Promise<Invite>;
51
+ /** Spend an invite on a new user. Single use; the inviter cannot redeem their own. */
52
+ redeemInvite(code: string, newUserId: string): Promise<RedeemInviteResult>;
53
+ /** Who invited this user (first redeemed invite), or null. */
54
+ invitedBy(userId: string): Promise<string | null>;
55
+ /** Users who joined on this inviter's invites, oldest first. */
56
+ invitees(inviterId: string): Promise<{
57
+ userId: string;
58
+ at: number;
59
+ }[]>;
60
+ /** Invites this user created, newest first, without codes (only hashes are stored). Includes revokedAt. */
61
+ invites(inviterId: string): Promise<(Omit<Invite, 'code'> & {
62
+ revokedAt: number | null;
63
+ })[]>;
64
+ /** Revoke an unused invite; it stops working and its quota slot is refunded. */
65
+ revokeInvite(inviteId: string): Promise<void>;
66
+ /** The user's public affiliate code: stable, created on first call, unlimited use. */
67
+ affiliateCode(userId: string): Promise<string>;
68
+ /** First-touch attribution of a new user to the affiliate who owns `code`. Never overwrites. */
69
+ attribute(code: string, newUserId: string): Promise<{
70
+ ok: boolean;
71
+ affiliateId?: string;
72
+ }>;
73
+ /** The affiliate a user is attributed to, or null. */
74
+ affiliateOf(userId: string): Promise<string | null>;
75
+ /** Discount for the new customer and commission for the affiliate, via @profullstack/referrals. */
76
+ commission(amountCents: number): {
77
+ discountCents: number;
78
+ commissionCents: number;
79
+ };
80
+ }
@@ -0,0 +1,157 @@
1
+ import { calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
2
+ import { generateAffiliateCode, generateInviteCode, hashCode, newId, normalizeCode } from "./codes.js";
3
+ const DAY = 24 * 60 * 60 * 1000;
4
+ function publicInvite(r) {
5
+ return {
6
+ id: r.id,
7
+ inviterId: r.inviterId,
8
+ createdAt: r.createdAt,
9
+ expiresAt: r.expiresAt,
10
+ usedBy: r.usedBy,
11
+ usedAt: r.usedAt,
12
+ note: r.note,
13
+ revokedAt: r.revokedAt,
14
+ };
15
+ }
16
+ /** Scarce invite codes plus open affiliate links over any AffiliateStore. */
17
+ export class Affiliates {
18
+ store;
19
+ now;
20
+ defaultQuota;
21
+ inviteTtlMs;
22
+ split;
23
+ constructor(store, opts = {}) {
24
+ this.store = store;
25
+ this.now = opts.now ?? Date.now;
26
+ this.defaultQuota = opts.defaultQuota ?? 3;
27
+ this.inviteTtlMs = opts.inviteTtlMs === undefined ? 30 * DAY : opts.inviteTtlMs;
28
+ this.split = opts.split ?? DEFAULT_SPLIT;
29
+ }
30
+ async total(userId) {
31
+ const q = await this.store.getQuota(userId);
32
+ return q ?? this.defaultQuota;
33
+ }
34
+ /** Invites a user may create, has created (revoked-before-use ones are refunded), and has left. */
35
+ async quota(userId) {
36
+ const [total, used] = await Promise.all([this.total(userId), this.store.countInvites(userId)]);
37
+ return { total, used, left: Math.max(0, total - used) };
38
+ }
39
+ /** Admin: set a user's invite allowance (total, not additional). */
40
+ async setQuota(userId, total) {
41
+ if (!Number.isInteger(total) || total < 0)
42
+ throw new Error('quota must be a non-negative integer');
43
+ await this.store.setQuota(userId, total);
44
+ }
45
+ /** Mint one invite. Throws Error('no invites left') when the quota is spent. */
46
+ async createInvite(inviterId, note = '') {
47
+ if (!inviterId)
48
+ throw new Error('inviterId required');
49
+ const limit = await this.total(inviterId);
50
+ const createdAt = this.now();
51
+ for (let attempt = 0; attempt < 5; attempt++) {
52
+ const code = generateInviteCode();
53
+ const row = {
54
+ id: newId(),
55
+ codeHash: hashCode(code),
56
+ inviterId,
57
+ createdAt,
58
+ expiresAt: this.inviteTtlMs === null ? null : createdAt + this.inviteTtlMs,
59
+ usedBy: null,
60
+ usedAt: null,
61
+ note: String(note ?? ''),
62
+ revokedAt: null,
63
+ };
64
+ let inserted;
65
+ try {
66
+ inserted = await this.store.insertInvite(row, limit);
67
+ }
68
+ catch (err) {
69
+ // 60-bit code collision: astronomically unlikely, retry with a fresh code
70
+ if (/unique|collision|constraint/i.test(String(err?.message)))
71
+ continue;
72
+ throw err;
73
+ }
74
+ if (!inserted)
75
+ throw new Error('no invites left');
76
+ const { revokedAt: _r, ...pub } = publicInvite(row);
77
+ return { code, ...pub };
78
+ }
79
+ throw new Error('could not mint a unique invite code');
80
+ }
81
+ /** Spend an invite on a new user. Single use; the inviter cannot redeem their own. */
82
+ async redeemInvite(code, newUserId) {
83
+ if (!code || !newUserId)
84
+ return { ok: false, reason: 'invalid' };
85
+ const row = await this.store.getInviteByHash(hashCode(code));
86
+ if (!row)
87
+ return { ok: false, reason: 'invalid' };
88
+ if (row.revokedAt !== null)
89
+ return { ok: false, reason: 'revoked' };
90
+ if (row.usedBy !== null)
91
+ return { ok: false, reason: 'used' };
92
+ if (row.inviterId === newUserId)
93
+ return { ok: false, reason: 'self' };
94
+ const at = this.now();
95
+ if (row.expiresAt !== null && at >= row.expiresAt)
96
+ return { ok: false, reason: 'expired' };
97
+ const won = await this.store.claimInvite(row.id, newUserId, at);
98
+ if (!won) {
99
+ const again = await this.store.getInvite(row.id);
100
+ return { ok: false, reason: again && again.revokedAt !== null && again.usedBy === null ? 'revoked' : 'used' };
101
+ }
102
+ return { ok: true, inviterId: row.inviterId, inviteId: row.id };
103
+ }
104
+ /** Who invited this user (first redeemed invite), or null. */
105
+ invitedBy(userId) {
106
+ return this.store.invitedBy(userId);
107
+ }
108
+ /** Users who joined on this inviter's invites, oldest first. */
109
+ invitees(inviterId) {
110
+ return this.store.invitees(inviterId);
111
+ }
112
+ /** Invites this user created, newest first, without codes (only hashes are stored). Includes revokedAt. */
113
+ async invites(inviterId) {
114
+ return (await this.store.listInvites(inviterId)).map(publicInvite);
115
+ }
116
+ /** Revoke an unused invite; it stops working and its quota slot is refunded. */
117
+ async revokeInvite(inviteId) {
118
+ await this.store.revokeInvite(inviteId, this.now());
119
+ }
120
+ /** The user's public affiliate code: stable, created on first call, unlimited use. */
121
+ async affiliateCode(userId) {
122
+ if (!userId)
123
+ throw new Error('userId required');
124
+ const existing = await this.store.getAffiliateCode(userId);
125
+ if (existing)
126
+ return existing;
127
+ for (let attempt = 0; attempt < 8; attempt++) {
128
+ const got = await this.store.putAffiliateCode(userId, generateAffiliateCode(), this.now());
129
+ if (got)
130
+ return got;
131
+ }
132
+ throw new Error('could not mint a unique affiliate code');
133
+ }
134
+ /** First-touch attribution of a new user to the affiliate who owns `code`. Never overwrites. */
135
+ async attribute(code, newUserId) {
136
+ if (!code || !newUserId)
137
+ return { ok: false };
138
+ const norm = normalizeCode(code);
139
+ const affiliateId = await this.store.affiliateByCode(norm);
140
+ if (!affiliateId || affiliateId === newUserId)
141
+ return { ok: false };
142
+ const inserted = await this.store.attribute({ userId: newUserId, affiliateId, code: norm, at: this.now() });
143
+ if (inserted)
144
+ return { ok: true, affiliateId };
145
+ const prior = await this.store.getAttribution(newUserId);
146
+ return { ok: false, ...(prior ? { affiliateId: prior.affiliateId } : {}) };
147
+ }
148
+ /** The affiliate a user is attributed to, or null. */
149
+ async affiliateOf(userId) {
150
+ return (await this.store.getAttribution(userId))?.affiliateId ?? null;
151
+ }
152
+ /** Discount for the new customer and commission for the affiliate, via @profullstack/referrals. */
153
+ commission(amountCents) {
154
+ const r = calculateReferral(amountCents, this.split);
155
+ return { discountCents: r.discountCents, commissionCents: r.commissionCents };
156
+ }
157
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
10
+ import { parseArgs } from 'node:util';
11
+ import { Affiliates } from "./affiliates.js";
12
+ import { sqliteStore } from "./sqlite.js";
13
+ const HELP = `affiliates - scarce invite codes and affiliate links on a SQLite file
14
+
15
+ Usage: affiliates [--db FILE] [--json] <command>
16
+
17
+ invite create <inviterId> [--note TEXT] mint one invite (prints the code once)
18
+ invite list <inviterId> list a user's invites (no codes, only hashes are stored)
19
+ invite revoke <inviteId> revoke an unused invite (refunds the quota slot)
20
+ invite redeem <code> <newUserId> spend an invite
21
+ quota get <userId> show total / used / left
22
+ quota set <userId> <total> give someone more (or fewer) invites
23
+ invitedby <userId> who invited this user
24
+ invitees <inviterId> who joined on this user's invites
25
+ code <userId> the user's affiliate code (created on first call)
26
+ attribute <code> <newUserId> first-touch affiliate attribution
27
+ affiliateof <userId> the affiliate a user is attributed to
28
+ commission <amountCents> discount + commission for an amount
29
+
30
+ Options:
31
+ --db FILE SQLite file (default $AFFILIATES_DB or ./affiliates.db)
32
+ --prefix P table prefix (default aff_)
33
+ --default-quota N invites per user without a custom quota (default 3)
34
+ --ttl-days N invite lifetime in days, 0 for none (default 30)
35
+ --json machine-readable output
36
+ -h, --help
37
+ `;
38
+ async function openDb(file) {
39
+ if (globalThis.Bun) {
40
+ const mod = await import(__rewriteRelativeImportExtension('bun:sqlite'));
41
+ return new mod.Database(file, { create: true });
42
+ }
43
+ const mod = await import(__rewriteRelativeImportExtension('node:sqlite'));
44
+ return new mod.DatabaseSync(file);
45
+ }
46
+ async function main(argv) {
47
+ const { values, positionals } = parseArgs({
48
+ args: argv,
49
+ allowPositionals: true,
50
+ options: {
51
+ db: { type: 'string' },
52
+ prefix: { type: 'string' },
53
+ 'default-quota': { type: 'string' },
54
+ 'ttl-days': { type: 'string' },
55
+ note: { type: 'string' },
56
+ json: { type: 'boolean', default: false },
57
+ help: { type: 'boolean', short: 'h', default: false },
58
+ },
59
+ });
60
+ const json = values.json === true;
61
+ const [cmd, sub, ...rest] = positionals;
62
+ if (values.help || !cmd) {
63
+ process.stdout.write(HELP);
64
+ return values.help ? 0 : 1;
65
+ }
66
+ const file = values.db ?? process.env.AFFILIATES_DB ?? './affiliates.db';
67
+ const ttlDays = values['ttl-days'] === undefined ? 30 : Number(values['ttl-days']);
68
+ const db = await openDb(file);
69
+ const aff = new Affiliates(sqliteStore(db, values.prefix ? { prefix: values.prefix } : {}), {
70
+ defaultQuota: values['default-quota'] === undefined ? 3 : Number(values['default-quota']),
71
+ inviteTtlMs: ttlDays > 0 ? ttlDays * 86_400_000 : null,
72
+ });
73
+ const need = (v, name) => {
74
+ if (!v)
75
+ throw new Error(`missing <${name}>`);
76
+ return v;
77
+ };
78
+ const out = (data, text) => {
79
+ process.stdout.write(json ? JSON.stringify(data, null, 2) + '\n' : text + '\n');
80
+ };
81
+ const when = (ms) => (ms === null ? '-' : new Date(ms).toISOString());
82
+ try {
83
+ if (cmd === 'invite') {
84
+ if (sub === 'create') {
85
+ const inv = await aff.createInvite(need(rest[0], 'inviterId'), values.note ?? '');
86
+ out(inv, `${inv.code}\n(id ${inv.id}, expires ${when(inv.expiresAt)}; the code is shown only once)`);
87
+ }
88
+ else if (sub === 'list') {
89
+ const list = await aff.invites(need(rest[0], 'inviterId'));
90
+ out(list, list.length === 0
91
+ ? 'no invites'
92
+ : list
93
+ .map((i) => {
94
+ const state = i.revokedAt !== null && !i.usedBy ? 'revoked' : i.usedBy ? `used by ${i.usedBy}` : 'open';
95
+ return `${i.id} ${when(i.createdAt)} ${state}${i.note ? ` ${i.note}` : ''}`;
96
+ })
97
+ .join('\n'));
98
+ }
99
+ else if (sub === 'revoke') {
100
+ const id = need(rest[0], 'inviteId');
101
+ await aff.revokeInvite(id);
102
+ out({ ok: true, id }, `revoked ${id}`);
103
+ }
104
+ else if (sub === 'redeem') {
105
+ const r = await aff.redeemInvite(need(rest[0], 'code'), need(rest[1], 'newUserId'));
106
+ out(r, r.ok ? `ok: invited by ${r.inviterId}` : `refused: ${r.reason}`);
107
+ return r.ok ? 0 : 2;
108
+ }
109
+ else
110
+ throw new Error('usage: invite create|list|revoke|redeem');
111
+ }
112
+ else if (cmd === 'quota') {
113
+ if (sub === 'set') {
114
+ const user = need(rest[0], 'userId');
115
+ await aff.setQuota(user, Number(need(rest[1], 'total')));
116
+ const q = await aff.quota(user);
117
+ out({ userId: user, ...q }, `${user}: ${q.left} of ${q.total} left`);
118
+ }
119
+ else if (sub === 'get') {
120
+ const user = need(rest[0], 'userId');
121
+ const q = await aff.quota(user);
122
+ out({ userId: user, ...q }, `${user}: ${q.left} of ${q.total} left (${q.used} used)`);
123
+ }
124
+ else
125
+ throw new Error('usage: quota get|set');
126
+ }
127
+ else if (cmd === 'invitedby') {
128
+ const who = await aff.invitedBy(need(sub, 'userId'));
129
+ out({ userId: sub, invitedBy: who }, who ?? 'nobody');
130
+ }
131
+ else if (cmd === 'invitees') {
132
+ const list = await aff.invitees(need(sub, 'inviterId'));
133
+ out(list, list.length ? list.map((i) => `${i.userId} ${when(i.at)}`).join('\n') : 'nobody yet');
134
+ }
135
+ else if (cmd === 'code') {
136
+ const code = await aff.affiliateCode(need(sub, 'userId'));
137
+ out({ userId: sub, code }, code);
138
+ }
139
+ else if (cmd === 'attribute') {
140
+ const r = await aff.attribute(need(sub, 'code'), need(rest[0], 'newUserId'));
141
+ out(r, r.ok ? `attributed to ${r.affiliateId}` : r.affiliateId ? `already attributed to ${r.affiliateId}` : 'refused');
142
+ return r.ok ? 0 : 2;
143
+ }
144
+ else if (cmd === 'affiliateof') {
145
+ const a = await aff.affiliateOf(need(sub, 'userId'));
146
+ out({ userId: sub, affiliateId: a }, a ?? 'none');
147
+ }
148
+ else if (cmd === 'commission') {
149
+ const amount = Number(need(sub, 'amountCents'));
150
+ const c = aff.commission(amount);
151
+ out({ amountCents: amount, ...c }, `discount ${c.discountCents}c, commission ${c.commissionCents}c`);
152
+ }
153
+ else {
154
+ throw new Error(`unknown command: ${cmd}`);
155
+ }
156
+ return 0;
157
+ }
158
+ finally {
159
+ db.close?.();
160
+ }
161
+ }
162
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
163
+ const msg = err instanceof Error ? err.message : String(err);
164
+ if (process.argv.includes('--json'))
165
+ process.stdout.write(JSON.stringify({ ok: false, error: msg }) + '\n');
166
+ else
167
+ process.stderr.write(`affiliates: ${msg}\n`);
168
+ process.exit(1);
169
+ });
@@ -0,0 +1,19 @@
1
+ /** Crockford base32 alphabet: no I, L, O, U. */
2
+ export declare const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
3
+ /** `n` random Crockford base32 characters from the CSPRNG (5 bits each). */
4
+ export declare function randomCrockford(n: number): string;
5
+ /** A new invite code, e.g. INV-7K3M-QX9D-2F8H (60 random bits). */
6
+ export declare function generateInviteCode(): string;
7
+ /** A new public affiliate code, 8 Crockford characters (40 random bits). */
8
+ export declare function generateAffiliateCode(): string;
9
+ /**
10
+ * Canonical form of a user-typed code: case-insensitive, ignores spaces,
11
+ * dashes and other punctuation, maps the Crockford look-alikes O->0 and I/L->1.
12
+ * Invite codes come back as INV-XXXX-XXXX-XXXX (the INV prefix is optional on
13
+ * input); anything else comes back as uppercase Crockford characters.
14
+ */
15
+ export declare function normalizeCode(input: string): string;
16
+ /** sha256 hex of the normalized code. Only this is stored. */
17
+ export declare function hashCode(code: string): string;
18
+ /** Random opaque id for rows. */
19
+ export declare function newId(): string;
package/dist/codes.js ADDED
@@ -0,0 +1,48 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ /** Crockford base32 alphabet: no I, L, O, U. */
3
+ export const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
4
+ /** `n` random Crockford base32 characters from the CSPRNG (5 bits each). */
5
+ export function randomCrockford(n) {
6
+ const bytes = randomBytes(n);
7
+ let out = '';
8
+ for (let i = 0; i < n; i++)
9
+ out += CROCKFORD[bytes[i] & 31];
10
+ return out;
11
+ }
12
+ /** A new invite code, e.g. INV-7K3M-QX9D-2F8H (60 random bits). */
13
+ export function generateInviteCode() {
14
+ const body = randomCrockford(12);
15
+ return `INV-${body.slice(0, 4)}-${body.slice(4, 8)}-${body.slice(8, 12)}`;
16
+ }
17
+ /** A new public affiliate code, 8 Crockford characters (40 random bits). */
18
+ export function generateAffiliateCode() {
19
+ return randomCrockford(8);
20
+ }
21
+ function crockfordFix(s) {
22
+ return s.replace(/O/g, '0').replace(/[IL]/g, '1');
23
+ }
24
+ /**
25
+ * Canonical form of a user-typed code: case-insensitive, ignores spaces,
26
+ * dashes and other punctuation, maps the Crockford look-alikes O->0 and I/L->1.
27
+ * Invite codes come back as INV-XXXX-XXXX-XXXX (the INV prefix is optional on
28
+ * input); anything else comes back as uppercase Crockford characters.
29
+ */
30
+ export function normalizeCode(input) {
31
+ const s = String(input ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
32
+ if (s.length === 15 && s.startsWith('INV'))
33
+ return formatInvite(crockfordFix(s.slice(3)));
34
+ if (s.length === 12)
35
+ return formatInvite(crockfordFix(s)); // invite typed without INV-
36
+ return crockfordFix(s);
37
+ }
38
+ function formatInvite(body) {
39
+ return `INV-${body.slice(0, 4)}-${body.slice(4, 8)}-${body.slice(8, 12)}`;
40
+ }
41
+ /** sha256 hex of the normalized code. Only this is stored. */
42
+ export function hashCode(code) {
43
+ return createHash('sha256').update(normalizeCode(code)).digest('hex');
44
+ }
45
+ /** Random opaque id for rows. */
46
+ export function newId() {
47
+ return randomBytes(12).toString('base64url');
48
+ }
@@ -0,0 +1,12 @@
1
+ export { Affiliates } from './affiliates.js';
2
+ export type { Invite, RedeemInviteResult, AffiliatesOptions } from './affiliates.js';
3
+ export type { AffiliateStore, InviteRow, AttributionRow } from './store.js';
4
+ export { memoryStore } from './memory.js';
5
+ export { sqliteStore } from './sqlite.js';
6
+ export type { SqliteLike } from './sqlite.js';
7
+ export { postgresStore } from './postgres.js';
8
+ export type { PgLike } from './postgres.js';
9
+ export type { SqlStoreOptions } from './sql.js';
10
+ export { normalizeCode, generateInviteCode, hashCode } from './codes.js';
11
+ export { buildReferralUrl, extractCode, calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
12
+ export type { ReferralSplit } from '@profullstack/referrals';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { Affiliates } from "./affiliates.js";
2
+ export { memoryStore } from "./memory.js";
3
+ export { sqliteStore } from "./sqlite.js";
4
+ export { postgresStore } from "./postgres.js";
5
+ export { normalizeCode, generateInviteCode, hashCode } from "./codes.js";
6
+ export { buildReferralUrl, extractCode, calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
@@ -0,0 +1,3 @@
1
+ import type { AffiliateStore } from './store.js';
2
+ /** In-process store for tests and prototypes. Every method body is synchronous, so it is atomic. */
3
+ export declare function memoryStore(): AffiliateStore;
package/dist/memory.js ADDED
@@ -0,0 +1,105 @@
1
+ const counted = (r) => r.revokedAt === null || r.usedBy !== null;
2
+ /** In-process store for tests and prototypes. Every method body is synchronous, so it is atomic. */
3
+ export function memoryStore() {
4
+ const quotas = new Map();
5
+ const invites = new Map();
6
+ const byHash = new Map();
7
+ const codes = new Map(); // userId -> code
8
+ const codeOwners = new Map(); // code -> userId
9
+ const attributions = new Map();
10
+ const copy = (r) => (r ? { ...r } : null);
11
+ return {
12
+ async getQuota(userId) {
13
+ return quotas.has(userId) ? quotas.get(userId) : null;
14
+ },
15
+ async setQuota(userId, total) {
16
+ quotas.set(userId, total);
17
+ },
18
+ async countInvites(inviterId) {
19
+ let n = 0;
20
+ for (const r of invites.values())
21
+ if (r.inviterId === inviterId && counted(r))
22
+ n++;
23
+ return n;
24
+ },
25
+ async insertInvite(row, limit) {
26
+ let n = 0;
27
+ for (const r of invites.values())
28
+ if (r.inviterId === row.inviterId && counted(r))
29
+ n++;
30
+ if (n >= limit)
31
+ return false;
32
+ if (byHash.has(row.codeHash))
33
+ throw new Error('invite code collision');
34
+ invites.set(row.id, { ...row });
35
+ byHash.set(row.codeHash, row.id);
36
+ return true;
37
+ },
38
+ async getInviteByHash(codeHash) {
39
+ const id = byHash.get(codeHash);
40
+ return id ? copy(invites.get(id)) : null;
41
+ },
42
+ async getInvite(id) {
43
+ return copy(invites.get(id));
44
+ },
45
+ async claimInvite(id, userId, at) {
46
+ const r = invites.get(id);
47
+ if (!r || r.usedBy !== null || r.revokedAt !== null)
48
+ return false;
49
+ r.usedBy = userId;
50
+ r.usedAt = at;
51
+ return true;
52
+ },
53
+ async revokeInvite(id, at) {
54
+ const r = invites.get(id);
55
+ if (r && r.revokedAt === null)
56
+ r.revokedAt = at;
57
+ },
58
+ async listInvites(inviterId) {
59
+ return [...invites.values()]
60
+ .filter((r) => r.inviterId === inviterId)
61
+ .sort((a, b) => b.createdAt - a.createdAt)
62
+ .map((r) => ({ ...r }));
63
+ },
64
+ async invitedBy(userId) {
65
+ let best = null;
66
+ for (const r of invites.values()) {
67
+ if (r.usedBy === userId && (best === null || (r.usedAt ?? 0) < (best.usedAt ?? 0)))
68
+ best = r;
69
+ }
70
+ return best ? best.inviterId : null;
71
+ },
72
+ async invitees(inviterId) {
73
+ return [...invites.values()]
74
+ .filter((r) => r.inviterId === inviterId && r.usedBy !== null)
75
+ .sort((a, b) => (a.usedAt ?? 0) - (b.usedAt ?? 0))
76
+ .map((r) => ({ userId: r.usedBy, at: r.usedAt }));
77
+ },
78
+ async getAffiliateCode(userId) {
79
+ return codes.get(userId) ?? null;
80
+ },
81
+ async putAffiliateCode(userId, code) {
82
+ const existing = codes.get(userId);
83
+ if (existing)
84
+ return existing;
85
+ if (codeOwners.has(code))
86
+ return null;
87
+ codes.set(userId, code);
88
+ codeOwners.set(code, userId);
89
+ return code;
90
+ },
91
+ async affiliateByCode(code) {
92
+ return codeOwners.get(code) ?? null;
93
+ },
94
+ async attribute(row) {
95
+ if (attributions.has(row.userId))
96
+ return false;
97
+ attributions.set(row.userId, { ...row });
98
+ return true;
99
+ },
100
+ async getAttribution(userId) {
101
+ const r = attributions.get(userId);
102
+ return r ? { ...r } : null;
103
+ },
104
+ };
105
+ }
@@ -0,0 +1,10 @@
1
+ import { type SqlStoreOptions } from './sql.js';
2
+ import type { AffiliateStore } from './store.js';
3
+ /** Anything with a node-postgres style `query` (pg.Pool, pg.Client, @profullstack/libsql-pg, ...). */
4
+ export interface PgLike {
5
+ query(sql: string, params?: unknown[]): Promise<{
6
+ rows: any[];
7
+ }>;
8
+ }
9
+ /** Store on Postgres. Creates its tables (prefix `aff_`) on first use. */
10
+ export declare function postgresStore(pool: PgLike, opts?: SqlStoreOptions): AffiliateStore;
@@ -0,0 +1,8 @@
1
+ import { sqlStore } from "./sql.js";
2
+ /** Store on Postgres. Creates its tables (prefix `aff_`) on first use. */
3
+ export function postgresStore(pool, opts = {}) {
4
+ return sqlStore(async (sql, params) => {
5
+ const res = await pool.query(sql, params.map((v) => (v === undefined ? null : v)));
6
+ return res?.rows ?? [];
7
+ }, opts, 'postgres');
8
+ }
package/dist/sql.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { AffiliateStore } from './store.js';
2
+ /** Runs one statement written with $1..$n placeholders and returns its rows. */
3
+ export type Exec = (sql: string, params: unknown[]) => Promise<any[]>;
4
+ export interface SqlStoreOptions {
5
+ /** Table name prefix. Default `aff_`. */
6
+ prefix?: string;
7
+ }
8
+ /** Internal: which engine the SQL runs on. */
9
+ export type Dialect = 'sqlite' | 'postgres';
10
+ export declare function ddl(prefix: string, dialect?: Dialect): string[];
11
+ /** Store over any SQL engine that speaks the common subset of SQLite and Postgres (RETURNING, ON CONFLICT). */
12
+ export declare function sqlStore(exec: Exec, opts?: SqlStoreOptions, dialect?: Dialect): AffiliateStore;
package/dist/sql.js ADDED
@@ -0,0 +1,172 @@
1
+ function checkPrefix(prefix) {
2
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix))
3
+ throw new Error(`invalid table prefix: ${prefix}`);
4
+ return prefix;
5
+ }
6
+ const num = (v) => (v === null || v === undefined ? null : Number(v));
7
+ const str = (v) => (v === null || v === undefined ? null : String(v));
8
+ function toInvite(r) {
9
+ return {
10
+ id: String(r.id),
11
+ codeHash: String(r.code_hash),
12
+ inviterId: String(r.inviter_id),
13
+ createdAt: Number(r.created_at),
14
+ expiresAt: num(r.expires_at),
15
+ usedBy: str(r.used_by),
16
+ usedAt: num(r.used_at),
17
+ note: r.note == null ? '' : String(r.note),
18
+ revokedAt: num(r.revoked_at),
19
+ };
20
+ }
21
+ export function ddl(prefix, dialect = 'sqlite') {
22
+ const p = checkPrefix(prefix);
23
+ const pgOnly = dialect === 'postgres'
24
+ ? [
25
+ // Quota check + insert under a per-inviter advisory lock. A plpgsql function takes a fresh
26
+ // snapshot per statement, so the count after the lock sees every committed invite; a single
27
+ // INSERT ... SELECT WHERE count < limit would race under READ COMMITTED.
28
+ `CREATE OR REPLACE FUNCTION ${p}insert_invite(
29
+ p_id TEXT, p_hash TEXT, p_inviter TEXT, p_created BIGINT, p_expires BIGINT, p_note TEXT, p_limit INTEGER
30
+ ) RETURNS BOOLEAN LANGUAGE plpgsql AS $fn$
31
+ BEGIN
32
+ PERFORM pg_advisory_xact_lock(hashtext('${p}invites'), hashtext(p_inviter));
33
+ IF (SELECT count(*) FROM ${p}invites
34
+ WHERE inviter_id = p_inviter AND (revoked_at IS NULL OR used_by IS NOT NULL)) >= p_limit THEN
35
+ RETURN FALSE;
36
+ END IF;
37
+ INSERT INTO ${p}invites (id, code_hash, inviter_id, created_at, expires_at, note)
38
+ VALUES (p_id, p_hash, p_inviter, p_created, p_expires, p_note);
39
+ RETURN TRUE;
40
+ END
41
+ $fn$`,
42
+ ]
43
+ : [];
44
+ return [
45
+ `CREATE TABLE IF NOT EXISTS ${p}invites (
46
+ id TEXT PRIMARY KEY,
47
+ code_hash TEXT NOT NULL UNIQUE,
48
+ inviter_id TEXT NOT NULL,
49
+ created_at BIGINT NOT NULL,
50
+ expires_at BIGINT,
51
+ used_by TEXT,
52
+ used_at BIGINT,
53
+ note TEXT NOT NULL DEFAULT '',
54
+ revoked_at BIGINT
55
+ )`,
56
+ `CREATE INDEX IF NOT EXISTS ${p}invites_inviter_idx ON ${p}invites (inviter_id)`,
57
+ `CREATE INDEX IF NOT EXISTS ${p}invites_used_by_idx ON ${p}invites (used_by)`,
58
+ `CREATE TABLE IF NOT EXISTS ${p}quotas (
59
+ user_id TEXT PRIMARY KEY,
60
+ total INTEGER NOT NULL
61
+ )`,
62
+ `CREATE TABLE IF NOT EXISTS ${p}codes (
63
+ user_id TEXT PRIMARY KEY,
64
+ code TEXT NOT NULL UNIQUE,
65
+ created_at BIGINT NOT NULL
66
+ )`,
67
+ `CREATE TABLE IF NOT EXISTS ${p}attributions (
68
+ user_id TEXT PRIMARY KEY,
69
+ affiliate_id TEXT NOT NULL,
70
+ code TEXT NOT NULL,
71
+ at BIGINT NOT NULL
72
+ )`,
73
+ `CREATE INDEX IF NOT EXISTS ${p}attributions_affiliate_idx ON ${p}attributions (affiliate_id)`,
74
+ ...pgOnly,
75
+ ];
76
+ }
77
+ /** Store over any SQL engine that speaks the common subset of SQLite and Postgres (RETURNING, ON CONFLICT). */
78
+ export function sqlStore(exec, opts = {}, dialect = 'sqlite') {
79
+ const p = checkPrefix(opts.prefix ?? 'aff_');
80
+ let ready = null;
81
+ const init = () => (ready ??= (async () => {
82
+ for (const stmt of ddl(p, dialect))
83
+ await exec(stmt, []);
84
+ })().catch((err) => {
85
+ ready = null;
86
+ throw err;
87
+ }));
88
+ const q = async (sql, params = []) => {
89
+ await init();
90
+ return exec(sql, params);
91
+ };
92
+ const COUNTED = `(revoked_at IS NULL OR used_by IS NOT NULL)`;
93
+ return {
94
+ async getQuota(userId) {
95
+ const rows = await q(`SELECT total FROM ${p}quotas WHERE user_id = $1`, [userId]);
96
+ return rows[0] ? Number(rows[0].total) : null;
97
+ },
98
+ async setQuota(userId, total) {
99
+ await q(`INSERT INTO ${p}quotas (user_id, total) VALUES ($1, $2)
100
+ ON CONFLICT (user_id) DO UPDATE SET total = excluded.total`, [userId, total]);
101
+ },
102
+ async countInvites(inviterId) {
103
+ const rows = await q(`SELECT count(*) AS n FROM ${p}invites WHERE inviter_id = $1 AND ${COUNTED}`, [inviterId]);
104
+ return Number(rows[0]?.n ?? 0);
105
+ },
106
+ async insertInvite(row, limit) {
107
+ if (dialect === 'postgres') {
108
+ const res = await q(`SELECT ${p}insert_invite($1, $2, $3, $4, $5, $6, $7) AS ok`, [
109
+ row.id, row.codeHash, row.inviterId, row.createdAt, row.expiresAt, row.note, limit,
110
+ ]);
111
+ return res[0]?.ok === true || res[0]?.ok === 't' || res[0]?.ok === 1;
112
+ }
113
+ // SQLite: writers are serialized, so the check and the insert cannot interleave.
114
+ const rows = await q(`INSERT INTO ${p}invites (id, code_hash, inviter_id, created_at, expires_at, used_by, used_at, note, revoked_at)
115
+ SELECT CAST($1 AS TEXT), CAST($2 AS TEXT), CAST($3 AS TEXT), CAST($4 AS BIGINT), CAST($5 AS BIGINT), NULL, NULL, CAST($6 AS TEXT), NULL
116
+ WHERE (SELECT count(*) FROM ${p}invites WHERE inviter_id = CAST($3 AS TEXT) AND ${COUNTED}) < CAST($7 AS INTEGER)
117
+ RETURNING id`, [row.id, row.codeHash, row.inviterId, row.createdAt, row.expiresAt, row.note, limit]);
118
+ return rows.length > 0;
119
+ },
120
+ async getInviteByHash(codeHash) {
121
+ const rows = await q(`SELECT * FROM ${p}invites WHERE code_hash = $1`, [codeHash]);
122
+ return rows[0] ? toInvite(rows[0]) : null;
123
+ },
124
+ async getInvite(id) {
125
+ const rows = await q(`SELECT * FROM ${p}invites WHERE id = $1`, [id]);
126
+ return rows[0] ? toInvite(rows[0]) : null;
127
+ },
128
+ async claimInvite(id, userId, at) {
129
+ const rows = await q(`UPDATE ${p}invites SET used_by = $2, used_at = $3
130
+ WHERE id = $1 AND used_by IS NULL AND revoked_at IS NULL RETURNING id`, [id, userId, at]);
131
+ return rows.length > 0;
132
+ },
133
+ async revokeInvite(id, at) {
134
+ await q(`UPDATE ${p}invites SET revoked_at = $2 WHERE id = $1 AND revoked_at IS NULL`, [id, at]);
135
+ },
136
+ async listInvites(inviterId) {
137
+ const rows = await q(`SELECT * FROM ${p}invites WHERE inviter_id = $1 ORDER BY created_at DESC, id`, [inviterId]);
138
+ return rows.map(toInvite);
139
+ },
140
+ async invitedBy(userId) {
141
+ const rows = await q(`SELECT inviter_id FROM ${p}invites WHERE used_by = $1 ORDER BY used_at ASC LIMIT 1`, [userId]);
142
+ return rows[0] ? String(rows[0].inviter_id) : null;
143
+ },
144
+ async invitees(inviterId) {
145
+ const rows = await q(`SELECT used_by, used_at FROM ${p}invites WHERE inviter_id = $1 AND used_by IS NOT NULL ORDER BY used_at ASC`, [inviterId]);
146
+ return rows.map((r) => ({ userId: String(r.used_by), at: Number(r.used_at) }));
147
+ },
148
+ async getAffiliateCode(userId) {
149
+ const rows = await q(`SELECT code FROM ${p}codes WHERE user_id = $1`, [userId]);
150
+ return rows[0] ? String(rows[0].code) : null;
151
+ },
152
+ async putAffiliateCode(userId, code, at) {
153
+ await q(`INSERT INTO ${p}codes (user_id, code, created_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, [userId, code, at]);
154
+ const rows = await q(`SELECT code FROM ${p}codes WHERE user_id = $1`, [userId]);
155
+ return rows[0] ? String(rows[0].code) : null;
156
+ },
157
+ async affiliateByCode(code) {
158
+ const rows = await q(`SELECT user_id FROM ${p}codes WHERE code = $1`, [code]);
159
+ return rows[0] ? String(rows[0].user_id) : null;
160
+ },
161
+ async attribute(row) {
162
+ const rows = await q(`INSERT INTO ${p}attributions (user_id, affiliate_id, code, at) VALUES ($1, $2, $3, $4)
163
+ ON CONFLICT (user_id) DO NOTHING RETURNING user_id`, [row.userId, row.affiliateId, row.code, row.at]);
164
+ return rows.length > 0;
165
+ },
166
+ async getAttribution(userId) {
167
+ const rows = await q(`SELECT * FROM ${p}attributions WHERE user_id = $1`, [userId]);
168
+ const r = rows[0];
169
+ return r ? { userId: String(r.user_id), affiliateId: String(r.affiliate_id), code: String(r.code), at: Number(r.at) } : null;
170
+ },
171
+ };
172
+ }
@@ -0,0 +1,26 @@
1
+ import { type SqlStoreOptions } from './sql.js';
2
+ import type { AffiliateStore } from './store.js';
3
+ interface SqliteStatement {
4
+ all(...params: any[]): any;
5
+ get?(...params: any[]): any;
6
+ run?(...params: any[]): any;
7
+ }
8
+ /**
9
+ * A synchronous SQLite handle: bun:sqlite `Database` (query/prepare + exec),
10
+ * better-sqlite3 `Database` (prepare + exec) or node:sqlite `DatabaseSync` (prepare + exec).
11
+ */
12
+ export type SqliteLike = {
13
+ query(sql: string): SqliteStatement;
14
+ exec(sql: string): unknown;
15
+ } | {
16
+ prepare(sql: string): SqliteStatement;
17
+ exec(sql: string): unknown;
18
+ };
19
+ /** Rewrite $1..$n to positional `?`, expanding params in order of appearance. */
20
+ export declare function toPositional(sql: string, params: unknown[]): {
21
+ sql: string;
22
+ params: unknown[];
23
+ };
24
+ /** Store on a synchronous SQLite handle. Creates its tables (prefix `aff_`) on first use. */
25
+ export declare function sqliteStore(db: SqliteLike, opts?: SqlStoreOptions): AffiliateStore;
26
+ export {};
package/dist/sqlite.js ADDED
@@ -0,0 +1,30 @@
1
+ import { sqlStore } from "./sql.js";
2
+ /** Rewrite $1..$n to positional `?`, expanding params in order of appearance. */
3
+ export function toPositional(sql, params) {
4
+ const out = [];
5
+ const text = sql.replace(/\$(\d+)/g, (_m, n) => {
6
+ out.push(params[Number(n) - 1]);
7
+ return '?';
8
+ });
9
+ return { sql: text, params: out.map((v) => (v === undefined ? null : v)) };
10
+ }
11
+ /** Store on a synchronous SQLite handle. Creates its tables (prefix `aff_`) on first use. */
12
+ export function sqliteStore(db, opts = {}) {
13
+ const anyDb = db;
14
+ const prep = typeof anyDb.query === 'function' ? (sql) => anyDb.query(sql) : (sql) => anyDb.prepare(sql);
15
+ return sqlStore(async (sql, params) => {
16
+ const { sql: text, params: args } = toPositional(sql, params);
17
+ if (args.length === 0 && /^\s*CREATE\s/i.test(text)) {
18
+ anyDb.exec(text);
19
+ return [];
20
+ }
21
+ const st = prep(text);
22
+ if (st.reader === false && st.run) {
23
+ // better-sqlite3 refuses .all() on statements that return no rows
24
+ st.run(...args);
25
+ return [];
26
+ }
27
+ const rows = st.all(...args);
28
+ return Array.isArray(rows) ? rows : [];
29
+ }, opts);
30
+ }
@@ -0,0 +1,56 @@
1
+ /** One invite as persisted. The plaintext code is never stored, only its sha256. */
2
+ export interface InviteRow {
3
+ id: string;
4
+ codeHash: string;
5
+ inviterId: string;
6
+ createdAt: number;
7
+ expiresAt: number | null;
8
+ usedBy: string | null;
9
+ usedAt: number | null;
10
+ note: string;
11
+ revokedAt: number | null;
12
+ }
13
+ export interface AttributionRow {
14
+ userId: string;
15
+ affiliateId: string;
16
+ code: string;
17
+ at: number;
18
+ }
19
+ /**
20
+ * Persistence contract. Every method is async so SQL and remote stores fit.
21
+ * The two methods that guard scarcity must be atomic in the store itself:
22
+ * `insertInvite` (quota check + insert) and `claimInvite` (single use).
23
+ *
24
+ * "Counted" invites are the ones that use up quota: every invite except one
25
+ * revoked before it was used.
26
+ */
27
+ export interface AffiliateStore {
28
+ /** Custom quota for a user, or null when the default applies. */
29
+ getQuota(userId: string): Promise<number | null>;
30
+ setQuota(userId: string, total: number): Promise<void>;
31
+ /** Number of counted invites created by this user. */
32
+ countInvites(inviterId: string): Promise<number>;
33
+ /** Insert only if the inviter has fewer than `limit` counted invites. Returns false when over quota. */
34
+ insertInvite(row: InviteRow, limit: number): Promise<boolean>;
35
+ getInviteByHash(codeHash: string): Promise<InviteRow | null>;
36
+ getInvite(id: string): Promise<InviteRow | null>;
37
+ /** Atomic single use: sets used_by/used_at only where used_by IS NULL and not revoked. True if this call won. */
38
+ claimInvite(id: string, userId: string, at: number): Promise<boolean>;
39
+ revokeInvite(id: string, at: number): Promise<void>;
40
+ /** All invites created by the user, newest first. */
41
+ listInvites(inviterId: string): Promise<InviteRow[]>;
42
+ /** Inviter of the earliest invite this user redeemed. */
43
+ invitedBy(userId: string): Promise<string | null>;
44
+ /** Users who redeemed this inviter's invites, oldest first. */
45
+ invitees(inviterId: string): Promise<{
46
+ userId: string;
47
+ at: number;
48
+ }[]>;
49
+ getAffiliateCode(userId: string): Promise<string | null>;
50
+ /** Insert if the user has none yet. Returns the user's code after the call (existing one wins), or null if `code` collided with another user's. */
51
+ putAffiliateCode(userId: string, code: string, at: number): Promise<string | null>;
52
+ affiliateByCode(code: string): Promise<string | null>;
53
+ /** First touch: insert only if the user has no attribution. True if inserted. */
54
+ attribute(row: AttributionRow): Promise<boolean>;
55
+ getAttribution(userId: string): Promise<AttributionRow | null>;
56
+ }
package/dist/store.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@profullstack/affiliates",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Scarce invite codes (per-user quota, single use, who-invited-whom) plus open affiliate links with first-touch attribution. Commission math from @profullstack/referrals. Memory, SQLite (bun:sqlite, node:sqlite, better-sqlite3) and Postgres stores, plus a CLI.",
6
+ "keywords": [
7
+ "invite",
8
+ "invite-codes",
9
+ "invite-only",
10
+ "affiliate",
11
+ "referral",
12
+ "attribution",
13
+ "sqlite",
14
+ "postgres",
15
+ "profullstack"
16
+ ],
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/profullstack/affiliates.git"
21
+ },
22
+ "homepage": "https://github.com/profullstack/affiliates#readme",
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "bin": {
34
+ "affiliates": "./dist/cli.js"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "scripts": {
42
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && sed -i -E \"s#(from '\\./[a-z-]+)\\.ts'#\\1.js'#\" dist/*.d.ts && chmod +x dist/cli.js",
43
+ "typecheck": "tsc --noEmit -p tsconfig.json",
44
+ "test": "node --experimental-strip-types --no-warnings --test test/*.test.ts",
45
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
46
+ },
47
+ "dependencies": {
48
+ "@profullstack/referrals": "^0.1.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^24.0.0",
52
+ "@types/pg": "^8.23.1",
53
+ "pg": "^8.23.1",
54
+ "typescript": "^5.9.3"
55
+ },
56
+ "engines": {
57
+ "node": ">=22.13"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
62
+ }