fleet-waitlist-submit 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/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # fleet-waitlist-submit
2
+
3
+ Server-side helper that commits waitlist signups as markdown to a developer's vault repo on GitHub. Part of the [Fleet](https://github.com/BiscayneDev/fleet) GTM toolkit.
4
+
5
+ ## How it fits
6
+
7
+ ```
8
+ Visitor's browser
9
+ └─ <form> on your landing page
10
+ └─ POST to YOUR API route
11
+ └─ fleet-waitlist-submit (this package)
12
+ └─ GitHub commit → your vault repo
13
+ └─ Fleet (running locally) sees the file,
14
+ matches against your contacts, notifies you
15
+ ```
16
+
17
+ The submission flow is **markdown-first**: the signup IS a file in your vault, not a row in a DB that maps to a file. No third-party form service required.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install fleet-waitlist-submit
23
+ ```
24
+
25
+ ## Use
26
+
27
+ ```ts
28
+ // app/api/waitlist/route.ts (Next.js example)
29
+ import { submit } from 'fleet-waitlist-submit'
30
+
31
+ export async function POST(req: Request) {
32
+ const body = await req.json()
33
+
34
+ const result = await submit({
35
+ githubToken: process.env.GITHUB_TOKEN!, // PAT with contents:write
36
+ vaultRepo: 'biscaynedev/halsey-vault', // owner/repo
37
+ product: 'inference', // your product slug
38
+ email: body.email,
39
+ name: body.name,
40
+ context: body.context,
41
+ source: 'web-form',
42
+ honeypot: body.website, // see "Spam protection" below
43
+ })
44
+
45
+ if (!result.ok) {
46
+ return Response.json({ error: result.error }, { status: 400 })
47
+ }
48
+ return Response.json({ ok: true })
49
+ }
50
+ ```
51
+
52
+ ## API
53
+
54
+ ```ts
55
+ submit({
56
+ githubToken: string, // GitHub PAT with contents:write on the vault repo
57
+ vaultRepo: string, // "owner/repo"
58
+ vaultBranch?: string, // defaults to repo's default branch
59
+ product: string, // lowercase letters, numbers, dashes
60
+ email: string, // RFC-ish validated
61
+ name?: string,
62
+ context?: string,
63
+ source?: string,
64
+ honeypot?: string, // if non-empty, silently discards
65
+ }) => Promise<SubmitResult>
66
+ ```
67
+
68
+ Returns `{ ok: true, signupId, commitSha, path }` on success, `{ ok: true, discarded: true }` on honeypot hit, or `{ ok: false, error }` on validation/API failure.
69
+
70
+ ## What it writes
71
+
72
+ A new file at `waitlists/<product>/signups/<timestamp-random>.md` in your vault repo:
73
+
74
+ ```yaml
75
+ ---
76
+ id: 2026-05-16T14-32-19-987Z-3f4a8c2d1e
77
+ product: inference
78
+ email: chris@usepod.ai
79
+ name: Chris G
80
+ source: web-form
81
+ createdAt: 2026-05-16T14:32:19.987Z
82
+ ---
83
+
84
+ Building agent infra
85
+ ```
86
+
87
+ Emails are normalized (lowercase, trimmed, `+alias` stripped) before storage to match Fleet's matcher convention.
88
+
89
+ ## Spam protection
90
+
91
+ - **Honeypot:** Add a hidden form field (e.g. `<input type="text" name="website" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px">`). Bots fill it; humans don't. Pass it as `honeypot`. Filled means silent discard — bots don't learn they're caught.
92
+ - **Rate limit at your API route**, not in this package. Helper has no opinion on that.
93
+ - For high-volume forms, add Turnstile/hCaptcha in your handler and only call `submit()` after verification.
94
+
95
+ ## GitHub token scope
96
+
97
+ Create a fine-grained PAT at <https://github.com/settings/tokens?type=beta> with:
98
+ - Repository access: only the vault repo
99
+ - Permissions: **Contents: read and write**
100
+
101
+ Store it as `GITHUB_TOKEN` in your server's env. **Never expose it client-side.**
102
+
103
+ ## Related
104
+
105
+ - [Fleet](https://github.com/BiscayneDev/fleet) — the local-first GTM brain that watches your vault and runs match logic
106
+ - [fleet-waitlist-widget](../waitlist-widget) — drop-in React form that posts to your handler
107
+
108
+ ## License
109
+
110
+ MIT © Halsey Huth
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Input to the submit() helper.
3
+ *
4
+ * Call this from your landing page's server-side form handler. It commits
5
+ * a markdown file under `waitlists/<product>/signups/<id>.md` in the
6
+ * developer's vault repo. Fleet running locally watches the vault and
7
+ * runs match logic against the dev's contacts.
8
+ */
9
+ export interface SubmitInput {
10
+ /** GitHub PAT with `contents:write` on the vault repo. */
11
+ githubToken: string;
12
+ /** Vault repo as `owner/repo`. e.g. `biscaynedev/halsey-vault`. */
13
+ vaultRepo: string;
14
+ /** Branch to commit to. Defaults to the repo's default branch. */
15
+ vaultBranch?: string;
16
+ /** Product slug (lowercase letters, numbers, dashes). */
17
+ product: string;
18
+ /** Submitter's email — required, validated. */
19
+ email: string;
20
+ /** Optional submitter name. */
21
+ name?: string;
22
+ /** Optional free-text context ("what are you building?"). */
23
+ context?: string;
24
+ /** Optional submission source (e.g. 'web-form', 'twitter-dm'). */
25
+ source?: string;
26
+ /** Honeypot field — if non-empty, the submission is silently dropped. */
27
+ honeypot?: string;
28
+ }
29
+ export type SubmitResult = {
30
+ ok: true;
31
+ signupId: string;
32
+ commitSha: string;
33
+ path: string;
34
+ } | {
35
+ ok: true;
36
+ discarded: true;
37
+ signupId: 'discarded';
38
+ } | {
39
+ ok: false;
40
+ error: string;
41
+ };
42
+ /**
43
+ * Commit a waitlist signup as a markdown file to a vault repo.
44
+ *
45
+ * Idempotency note: the signupId includes a random suffix, so the same
46
+ * email can be submitted multiple times without conflict (Fleet's
47
+ * matcher dedupes by email when surfacing matches, not by file). If
48
+ * you need strict one-per-email semantics, dedupe in your form handler
49
+ * before calling.
50
+ */
51
+ export declare function submit(input: SubmitInput): Promise<SubmitResult>;
52
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA;;;;;;;GAOG;AACH,MAAM,WAAW,WAAW;IAC1B,0DAA0D;IAC1D,WAAW,EAAE,MAAM,CAAC;IACpB,mEAAmE;IACnE,SAAS,EAAE,MAAM,CAAC;IAClB,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yDAAyD;IACzD,OAAO,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,KAAK,EAAE,MAAM,CAAC;IACd,+BAA+B;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6DAA6D;IAC7D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAoBD,MAAM,MAAM,YAAY,GACpB;IACE,EAAE,EAAE,IAAI,CAAC;IACT,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd,GACD;IACE,EAAE,EAAE,IAAI,CAAC;IACT,SAAS,EAAE,IAAI,CAAC;IAChB,QAAQ,EAAE,WAAW,CAAC;CACvB,GACD;IACE,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAuCN;;;;;;;;GAQG;AACH,wBAAsB,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC,CAiDtE"}
package/dist/index.js ADDED
@@ -0,0 +1,107 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { Octokit } from '@octokit/rest';
3
+ import matter from 'gray-matter';
4
+ import { z } from 'zod';
5
+ const submitInputSchema = z.object({
6
+ githubToken: z.string().min(1),
7
+ vaultRepo: z
8
+ .string()
9
+ .regex(/^[^/\s]+\/[^/\s]+$/, 'vaultRepo must be in owner/repo form'),
10
+ vaultBranch: z.string().optional(),
11
+ product: z
12
+ .string()
13
+ .regex(/^[a-z0-9][a-z0-9-]*$/, {
14
+ message: 'product must be lowercase alphanumerics and dashes',
15
+ }),
16
+ email: z.email().max(254),
17
+ name: z.string().max(200).optional(),
18
+ context: z.string().max(2000).optional(),
19
+ source: z.string().max(50).optional(),
20
+ honeypot: z.string().optional(),
21
+ });
22
+ function normalizeEmail(email) {
23
+ const trimmed = email.trim().toLowerCase();
24
+ const atIdx = trimmed.lastIndexOf('@');
25
+ if (atIdx < 0)
26
+ return trimmed;
27
+ const local = trimmed.slice(0, atIdx);
28
+ const domain = trimmed.slice(atIdx);
29
+ const plusIdx = local.indexOf('+');
30
+ if (plusIdx < 0)
31
+ return trimmed;
32
+ return local.slice(0, plusIdx) + domain;
33
+ }
34
+ function makeSignupId() {
35
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
36
+ const rand = randomBytes(5).toString('hex');
37
+ return `${ts}-${rand}`;
38
+ }
39
+ function renderSignupMarkdown(opts) {
40
+ const frontmatter = {
41
+ id: opts.signupId,
42
+ product: opts.product,
43
+ email: opts.email,
44
+ createdAt: opts.createdAt,
45
+ };
46
+ if (opts.name)
47
+ frontmatter.name = opts.name;
48
+ if (opts.source)
49
+ frontmatter.source = opts.source;
50
+ return matter.stringify(opts.context, frontmatter);
51
+ }
52
+ /**
53
+ * Commit a waitlist signup as a markdown file to a vault repo.
54
+ *
55
+ * Idempotency note: the signupId includes a random suffix, so the same
56
+ * email can be submitted multiple times without conflict (Fleet's
57
+ * matcher dedupes by email when surfacing matches, not by file). If
58
+ * you need strict one-per-email semantics, dedupe in your form handler
59
+ * before calling.
60
+ */
61
+ export async function submit(input) {
62
+ const parsed = submitInputSchema.safeParse(input);
63
+ if (!parsed.success) {
64
+ return {
65
+ ok: false,
66
+ error: parsed.error.issues
67
+ .map((i) => `${i.path.join('.')}: ${i.message}`)
68
+ .join('; '),
69
+ };
70
+ }
71
+ // Honeypot — silently accept and drop
72
+ if (parsed.data.honeypot && parsed.data.honeypot.trim().length > 0) {
73
+ return { ok: true, discarded: true, signupId: 'discarded' };
74
+ }
75
+ const data = parsed.data;
76
+ const [owner, repo] = data.vaultRepo.split('/');
77
+ const octokit = new Octokit({ auth: data.githubToken });
78
+ const signupId = makeSignupId();
79
+ const createdAt = new Date().toISOString();
80
+ const path = `waitlists/${data.product}/signups/${signupId}.md`;
81
+ const content = renderSignupMarkdown({
82
+ signupId,
83
+ product: data.product,
84
+ email: normalizeEmail(data.email),
85
+ name: data.name,
86
+ source: data.source,
87
+ createdAt,
88
+ context: data.context ?? '',
89
+ });
90
+ try {
91
+ const res = await octokit.repos.createOrUpdateFileContents({
92
+ owner,
93
+ repo,
94
+ path,
95
+ message: `waitlist: ${data.product} signup`,
96
+ content: Buffer.from(content, 'utf8').toString('base64'),
97
+ branch: data.vaultBranch,
98
+ });
99
+ const commitSha = res.data.commit.sha ?? '';
100
+ return { ok: true, signupId, commitSha, path };
101
+ }
102
+ catch (err) {
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ return { ok: false, error: `GitHub commit failed: ${message}` };
105
+ }
106
+ }
107
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "fleet-waitlist-submit",
3
+ "version": "0.1.0",
4
+ "description": "Server-side helper that commits waitlist signups as markdown to a developer's vault repo on GitHub. Part of the Fleet GTM toolkit.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "typecheck": "tsc --noEmit",
22
+ "clean": "rm -rf dist",
23
+ "prepublishOnly": "npm run clean && npm run build"
24
+ },
25
+ "keywords": [
26
+ "fleet",
27
+ "waitlist",
28
+ "obsidian",
29
+ "vault",
30
+ "github",
31
+ "markdown",
32
+ "gtm"
33
+ ],
34
+ "author": "Halsey Huth <halsey.huth@gmail.com>",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/BiscayneDev/fleet.git",
39
+ "directory": "packages/waitlist-submit"
40
+ },
41
+ "homepage": "https://github.com/BiscayneDev/fleet/tree/main/packages/waitlist-submit#readme",
42
+ "engines": {
43
+ "node": ">=20.9.0"
44
+ },
45
+ "dependencies": {
46
+ "@octokit/rest": "^22.0.1",
47
+ "gray-matter": "^4.0.3",
48
+ "zod": "^4.3.6"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^20",
52
+ "typescript": "^5",
53
+ "vitest": "^3.2.4"
54
+ }
55
+ }