From c568b76798511f61bce4985c2c260ad29a10579a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 15 Oct 2026 00:33:56 +0000 Subject: [PATCH] Accounts, 339-destination catalog, assisted submissions and a real app - Accounts: passkeys (WebAuthn), email + password (scrypt, lockout, no enumeration), emailed links via Resend; per-user workspace; cookie sessions with CSRF checks - OAuth 2.1 + PKCE authorization server for the CLI: submitfleet login/logout/whoami, rotating refresh with family revocation; CLI commands and mcp --stdio use the account - Catalog: the CC0 awesome-submitlist catalog (339 destinations) with price, eligibility, audience, link type and traffic; imports never enable automation - Assisted submissions: copy kit per destination from approved facts, mark submitted / live (listing URL) / rejected / skipped; relevance ranking keeps AI-only, platform-only and other mismatched directories unchecked - URL autofill with SSRF guards (DNS checked at connect time), logo upload/import - Landing page, docs, sign-up/sign-in, app: products, launch planner, campaign queue, destinations, settings (passkeys, password, API keys) - 67 tests; browser e2e incl. passkey sign-in and CLI login Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 1 + README.md | 32 +- catalog/submitlist.LICENSE | 121 + catalog/submitlist.json | 7185 ++++++++++++++++++++++++++++++++++++ package.json | 7 +- pnpm-lock.yaml | 234 ++ src/assisted.ts | 99 + src/auth.ts | 299 ++ src/campaigns.ts | 117 +- src/catalog.ts | 176 +- src/cli.ts | 147 +- src/dashboard.ts | 203 - src/extract.ts | 176 + src/http.ts | 99 +- src/installer.ts | 31 + src/ledger.ts | 2 + src/mail.ts | 57 + src/mcp.ts | 10 +- src/migrations.ts | 112 + src/oauth.ts | 139 + src/relevance.ts | 47 + src/remote.ts | 161 + src/service.ts | 191 +- src/web.ts | 336 ++ src/workspaces.ts | 18 +- test/assisted.test.ts | 125 + test/http.test.ts | 12 +- test/relevance.test.ts | 27 + test/web.test.ts | 243 ++ web/app.html | 38 + web/assets/app.css | 88 + web/assets/app.js | 703 ++++ web/assets/auth.js | 116 + web/assets/dest.svg | 1 + web/assets/icon.svg | 1 + web/assets/site.css | 183 + web/assets/webauthn.js | 61 + web/auth.html | 46 + web/authorize.html | 29 + web/docs.html | 67 + web/error.html | 17 + web/index.html | 107 + web/link.html | 24 + 43 files changed, 11551 insertions(+), 337 deletions(-) create mode 100644 catalog/submitlist.LICENSE create mode 100644 catalog/submitlist.json create mode 100644 src/assisted.ts create mode 100644 src/auth.ts delete mode 100644 src/dashboard.ts create mode 100644 src/extract.ts create mode 100644 src/installer.ts create mode 100644 src/mail.ts create mode 100644 src/oauth.ts create mode 100644 src/relevance.ts create mode 100644 src/remote.ts create mode 100644 src/web.ts create mode 100644 test/assisted.test.ts create mode 100644 test/relevance.test.ts create mode 100644 test/web.test.ts create mode 100644 web/app.html create mode 100644 web/assets/app.css create mode 100644 web/assets/app.js create mode 100644 web/assets/auth.js create mode 100644 web/assets/dest.svg create mode 100644 web/assets/icon.svg create mode 100644 web/assets/site.css create mode 100644 web/assets/webauthn.js create mode 100644 web/auth.html create mode 100644 web/authorize.html create mode 100644 web/docs.html create mode 100644 web/error.html create mode 100644 web/index.html create mode 100644 web/link.html diff --git a/Dockerfile b/Dockerfile index 5d50044..32a1584 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,7 @@ ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 SUBMITFLEET_HOME=/data # --chown: the deploy account's umask 007 leaves the checkout unreadable to other users. COPY --chown=node:node package.json ./ COPY --chown=node:node catalog ./catalog +COPY --chown=node:node web ./web COPY --from=deps --chown=node:node /app/node_modules ./node_modules COPY --from=build --chown=node:node /app/dist ./dist RUN mkdir -p /data && chown node:node /data diff --git a/README.md b/README.md index 579bc41..956c0db 100644 --- a/README.md +++ b/README.md @@ -7,18 +7,21 @@ sites and directories. It keeps one approved product profile, plans a bounded ca owner to approve exactly what will be sent, runs durable workers, and records receipts and verified listings. The same core serves a CLI, a TUI, an HTTP API, an MCP server and a mobile dashboard. -## Status: early engine, below the release gate - -| | | -| --- | --- | -| Release-qualifying destinations | **0 of 100 required** (`submitfleet coverage report`) | -| Adapters shipped | 1 (TinyLaunch, contract-tested against its published agent guide; no live submission yet) | -| Deployed | No. Nothing runs at submitfleet.com yet. | -| Submissions made | None | - -This build is the foundation the 100-destination release is measured against, not that release. -It does not claim traffic, ranking or customer outcomes. The PRD's value pilot, design-partner -evidence and the adapter build-out to 100+ are still ahead. +## Status + +Live at https://submitfleet.com. + +- **Accounts:** sign up with a passkey, email + password, or an emailed link. Each account gets its own + workspace. `submitfleet login` signs the CLI in through the browser (OAuth 2.1 + PKCE). +- **Catalog:** 339 launch sites and directories (the CC0 awesome-submitlist catalog), 203 free to list, + with price, eligibility, audience, link type and traffic for each. +- **Assisted submissions:** for every destination SubmitFleet prepares each form field from your approved + product facts, links the submit page and tracks submitted, live (with the listing URL), rejected or skipped. + Destinations are ranked by fit, and niche or platform-only directories that do not match your product are + left unchecked. +- **Automatic submissions:** only where a destination's API permits it and an adapter has been proven + against it. Today that is **0 of 339** (`submitfleet coverage report`); the TinyLaunch adapter is + contract-tested but has not made a live submission yet. ## What the engine enforces @@ -106,12 +109,9 @@ record carries contract-test and live-submission evidence through a reviewed reg ## Known gaps against the PRD - 99+ more qualifying adapters, and the first live TinyLaunch submission. -- Fact extraction from a product URL (products are entered, then owner-approved). -- Dashboard sign-in is an API key; the house magic-link and passkey sign-in is not wired yet. -- The dashboard is server-rendered HTML rather than Next.js. +- The dashboard is plain HTML + JS served by the app rather than Next.js. - Browser-adapter runtime (isolated profiles, selectors, receipts) is not built. - Analytics integration: outcomes can be recorded but no provider is connected. -- Hosting, DNS and TLS for submitfleet.com are not configured. ## License diff --git a/catalog/submitlist.LICENSE b/catalog/submitlist.LICENSE new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/catalog/submitlist.LICENSE @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/catalog/submitlist.json b/catalog/submitlist.json new file mode 100644 index 0000000..80a9003 --- /dev/null +++ b/catalog/submitlist.json @@ -0,0 +1,7185 @@ +{ + "source": "https://api.submitlist.io/catalog/destinations", + "synced_on": "2026-10-12", + "count": 339, + "destinations": [ + { + "slug": "acquire", + "name": "Acquire.com", + "type": "marketplace", + "website_url": "https://acquire.com/", + "submission_url": "https://acquire.com/sellers", + "logo_url": "https://www.google.com/s2/favicons?domain=acquire.com&sz=64", + "description": "Acquire.com is an online marketplace for buying and selling startups and online businesses, with tools for listings, buyer matching, and deal support.", + "audience": "Owners of operating startups and online businesses seeking to sell a verified business through a marketplace.", + "eligibility": "Eligible listings are primarily profitable, operating, verifiable online businesses such as SaaS, ecommerce, Shopify apps, marketplaces, agencies, newsletters, communities, AI, or content businesses; pre-revenue listings are limited exceptions, and adult, gambling, weapons, beta, non-verifiable, brick-and-mortar, franchise, and MLM businesses are excluded.", + "pricing": "paid", + "pricing_note": "A live listing costs US$25–100 per month based on asking price, plus a 6–8% closing fee only when the business sells.", + "link_type": "nofollow", + "domain_rating": 73, + "monthly_traffic": 34900, + "categories": [ + "business", + "saas", + "startup" + ] + }, + { + "slug": "affiliate-watch", + "name": "Affiliate Watch", + "type": "directory", + "website_url": "https://affiliate.watch/", + "submission_url": "https://affiliate.watch/submit-affiliate-program", + "logo_url": "https://www.google.com/s2/favicons?domain=affiliate.watch&sz=64", + "description": "Affiliate-program discovery directory with categorized program profiles, commission details, reviews, ratings, networks, software, and submission options for program owners.", + "audience": "Affiliate managers, brands with active affiliate programs, affiliate networks, partnership teams, and publishers comparing programs.", + "eligibility": "The listing must represent a real, active affiliate program. Submit accurate program, commission, category, and destination details through the official workflow; ordinary products without an affiliate program are not eligible.", + "pricing": "free", + "pricing_note": "The official submission page states that free and paid listing options are available; final paid pricing is shown during the submission workflow.", + "link_type": "unknown", + "domain_rating": 53, + "monthly_traffic": 18800, + "categories": [ + "business", + "marketing" + ] + }, + { + "slug": "ai-directories", + "name": "AI Directories", + "type": "directory", + "website_url": "https://www.aidirectori.es/", + "submission_url": "https://www.aidirectori.es/submit-ai-tool", + "logo_url": "https://www.google.com/s2/favicons?domain=aidirectori.es&sz=64", + "description": "AI Directories maintains a curated catalog of AI tools and directories, and offers a service for submitting tools to external directories.", + "audience": "Founders and startups with AI tools seeking a catalog listing, directory discovery, and backlinks.", + "eligibility": "The catalog submission route is for AI tools; the site also maintains curated non-AI tool lists.", + "pricing": "free", + "pricing_note": "Submitting an AI tool to the site's own catalog is free; one-time paid packages starting around US$99 cover manual submission to external directories.", + "link_type": "dofollow", + "domain_rating": 71, + "monthly_traffic": 38700, + "categories": [ + "ai" + ] + }, + { + "slug": "ai-hunter", + "name": "AI-Hunter.io", + "type": "directory", + "website_url": "https://ai-hunter.io/", + "submission_url": "https://ai-hunter.io/submit-a-tool/", + "logo_url": "https://ai-hunter.io/wp-content/uploads/2024/03/ai-hunter-favicon.png", + "description": "AI-tool discovery directory covering thousands of products across business, content, development, image, video, productivity, research, and other AI categories.", + "audience": "AI-product founders and developers seeking directory discovery among users browsing categorized artificial-intelligence tools.", + "eligibility": "The submission must be a functioning AI tool with a valid website. The free form requires an appropriate category, pricing classification, and original description rather than copied promotional text.", + "pricing": "free", + "pricing_note": "The standard submission form is explicitly free; a separate paid fast-submission option promises review in under 24 hours.", + "link_type": "unknown", + "domain_rating": 20, + "monthly_traffic": 1400, + "categories": [ + "ai" + ] + }, + { + "slug": "ai-parabellum", + "name": "AI Parabellum", + "type": "directory", + "website_url": "https://aiparabellum.com/", + "submission_url": "https://aiparabellum.com/submit-your-ai-tool/", + "logo_url": "https://www.google.com/s2/favicons?domain=aiparabellum.com&sz=64", + "description": "AI Parabellum is a paid, manually reviewed AI-tools directory that lists approved tools on a dedicated page.", + "audience": "Founders of functional AI-powered software seeking a manually reviewed paid directory listing.", + "eligibility": "Tools need a working URL and must be functional AI software; NSFW, gambling, malware, illegal-use, scraped, spam, broken, placeholder, and AI-directory listings are rejected.", + "pricing": "paid", + "pricing_note": "Basic costs $39 one time and Featured costs $89 one time; the site states it refunds a tool that fails manual review.", + "link_type": "dofollow", + "domain_rating": 34, + "monthly_traffic": 1200, + "categories": [ + "ai", + "saas", + "startup" + ] + }, + { + "slug": "ai-tools-directory", + "name": "AI Tools Directory", + "type": "directory", + "website_url": "https://aitoolsdirectory.com/", + "submission_url": "https://aitoolsdirectory.com/submit-tool", + "logo_url": "https://www.google.com/s2/favicons?domain=aitoolsdirectory.com&sz=64", + "description": "AI Tools Directory is a curated directory of AI software and tools for entrepreneurs, creators, and innovators.", + "audience": "Makers of AI tools and software seeking reviewed directory discovery.", + "eligibility": "Submissions must be AI tools or software and are reviewed before inclusion.", + "pricing": "free", + "pricing_note": "Submission is free; the site describes itself as a free resource without subscription fees, while featured or premium placement options may exist.", + "link_type": "nofollow", + "domain_rating": 52, + "monthly_traffic": 26000, + "categories": [ + "ai", + "saas" + ] + }, + { + "slug": "ai-tools-explorer", + "name": "AI Tools Explorer", + "type": "directory", + "website_url": "https://aitoolsexplorer.com/", + "submission_url": "https://aitoolsexplorer.com/submit-ai-tool/", + "logo_url": "https://aitoolsexplorer.com/wp-content/uploads/2026/05/AITE-Logo-v2-blue.svg", + "description": "Curated AI-tool directory with human-written lifetime profile pages, category and feature filters, pricing information, target audiences, integrations, models, and editorial completeness scores.", + "audience": "Founders and marketers of established AI tools seeking a reviewed directory profile among users comparing safe-for-work AI products.", + "eligibility": "Submissions must be safe for work, have a functioning website, use a specific non-generic product name, and state pricing clearly. Every tool is reviewed by a person before publication.", + "pricing": "paid", + "pricing_note": "One-time paid submissions are listed at $49 for AI Visa and $129 for AI Passport. The Passport includes a fuller editorial profile and two future profile updates; later additional updates cost $25.", + "link_type": "nofollow", + "domain_rating": 35, + "monthly_traffic": 3600, + "categories": [ + "ai" + ] + }, + { + "slug": "ai-tools-inc", + "name": "AI Tools", + "type": "directory", + "website_url": "https://aitools.inc/", + "submission_url": "https://aitools.inc/submit", + "logo_url": "https://aitools.inc/icon-512.png", + "description": "AI-tool discovery directory organized by use case, task, job role, category, and pricing, with product profiles, educational material, and an associated AI-automation community.", + "audience": "AI-tool founders and marketers seeking discovery among automation builders, operators, and users comparing products for specific workflows.", + "eligibility": "Submissions must represent a functioning AI tool and provide enough accurate product information for review. Use the standard free form unless expedited publication is required.", + "pricing": "free", + "pricing_note": "A standard listing is free at $0 per month. An optional one-time $129 Fast Lane submission provides the expedited route shown on the official submission page.", + "link_type": "unknown", + "domain_rating": 67, + "monthly_traffic": 23200, + "categories": [ + "ai", + "productivity" + ] + }, + { + "slug": "ai-with-me", + "name": "AI With Me", + "type": "directory", + "website_url": "https://aiwith.me/", + "submission_url": "https://aiwith.me/submit/", + "logo_url": "https://www.google.com/s2/favicons?domain=aiwith.me&sz=64", + "description": "AI With Me is a directory of thousands of AI tools across categories including writing, image, code, marketing, and productivity.", + "audience": "Teams with accessible AI tools seeking discovery through an AI-tools directory.", + "eligibility": "Submissions must be AI tools with accessible websites that meet the directory's AI-tool criteria; non-AI or inaccessible sites are rejected.", + "pricing": "free", + "pricing_note": "Free submissions are available with a longer wait and lower visibility; paid one-time and monthly plans offer faster listing, higher placement, or sponsorship.", + "link_type": "dofollow", + "domain_rating": 58, + "monthly_traffic": 3900, + "categories": [ + "ai", + "saas" + ] + }, + { + "slug": "aitoolnet", + "name": "Aitoolnet", + "type": "directory", + "website_url": "https://www.aitoolnet.com/", + "submission_url": "https://www.aitoolnet.com/submit/", + "logo_url": "https://www.google.com/s2/favicons?domain=aitoolnet.com&sz=64", + "description": "Aitoolnet is a large AI tools directory and search engine that catalogs tens of thousands of curated AI tools by use case and category.", + "audience": "Makers of AI tools seeking discovery through an AI-tool directory and search engine.", + "eligibility": "Only AI tools are eligible; newsletters and directories are excluded, the tool must not already be listed, and submissions require manual review and approval.", + "pricing": "free", + "pricing_note": "A free tier is published within about 30 days and includes a dofollow backlink; paid one-time options are $9.90 for a roughly 24-hour listing and $49.90 for seven days of featuring.", + "link_type": "nofollow", + "domain_rating": 56, + "monthly_traffic": 2100, + "categories": [ + "ai" + ] + }, + { + "slug": "aitoptools", + "name": "AITopTools", + "type": "directory", + "website_url": "https://aitoptools.com/", + "submission_url": "https://aitoptools.com/claim-your-tool/", + "logo_url": "https://www.google.com/s2/favicons?domain=aitoptools.com&sz=64", + "description": "AITopTools is an AI-tools directory with curated tool listings, categories, new tools, and user reviews.", + "audience": "Makers of AI tools with public websites seeking a paid directory listing.", + "eligibility": "The tool must be an AI tool with a public website; publication requires payment.", + "pricing": "paid", + "pricing_note": "Instant Launch permanent listings cost about $59–$69; higher packages and a monthly option are also available.", + "link_type": "nofollow", + "domain_rating": 54, + "monthly_traffic": 27300, + "categories": [ + "ai", + "saas" + ] + }, + { + "slug": "aixploria", + "name": "AIxploria", + "type": "directory", + "website_url": "https://www.aixploria.com/", + "submission_url": "https://www.aixploria.com/en/submit-ai-tool-or-feature-company/", + "logo_url": "https://www.google.com/s2/favicons?domain=aixploria.com&sz=64", + "description": "AIxploria is a large AI-tools directory organized by category with search, top-10 lists, and daily updates for discovering and comparing AI products.", + "audience": "Makers of reviewed AI tools seeking paid directory discovery and featuring opportunities.", + "eligibility": "Only AI-related tools are eligible and require editorial approval; NSFW content, affiliate or shortened URLs, directories, spam, duplicates, poorly designed sites, and AI-generated submission text are excluded, and payment is required before publication.", + "pricing": "paid", + "pricing_note": "Fast Listing is $79 one-time, Verified Listing is $279 one-time, and Featured Ads are $399 for a duration-based placement; free listings are no longer offered.", + "link_type": "nofollow", + "domain_rating": 57, + "monthly_traffic": 131000, + "categories": [ + "ai" + ] + }, + { + "slug": "all-my-faves", + "name": "All my faves", + "type": "citation", + "website_url": "https://allmyfaves.com/", + "submission_url": "https://allmyfaves.com/faq", + "logo_url": "https://www.google.com/s2/favicons?domain=allmyfaves.com&sz=64", + "description": "All my faves is a visual, curated directory and personalized homepage of favorite websites organized by categories and Favelines.", + "audience": "Websites with an exceptional, relevant product or service that could be selected by All my faves' editorial team.", + "eligibility": "The site must be exceptional, relevant, and appropriate enough for the editors to select it for a Faveline or Weekly Fave.", + "pricing": "free", + "pricing_note": "Editorial inclusion is stated to be free if the site is accepted.", + "link_type": "dofollow", + "domain_rating": 71, + "monthly_traffic": 2500, + "categories": [ + "business", + "productivity" + ] + }, + { + "slug": "alphaandbetausers", + "name": "r/alphaandbetausers", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/alphaandbetausers/", + "submission_url": "https://www.reddit.com/r/alphaandbetausers/submit", + "logo_url": "https://styles.redditmedia.com/t5_2znn4/styles/communityIcon_b55xn3gqq2m61.png?width=256&s=d389d2c73f5f6ec0d29627706757c885ae116a1c", + "description": "Our innovators need people to use their products to give them feedback! It is hard enough to develop a product. Let's make it easier for them to recruit the critical early users that they need to tell them how to make their products better.", + "audience": "Innovators seeking early users to try products and provide feedback.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "community", + "developer-tools", + "startup" + ], + "subreddit": { + "members_count": 43461, + "growth_30d_absolute": 3226, + "promotion_mode": "unknown", + "rules_url": "https://www.reddit.com/r/alphaandbetausers/about/rules" + } + }, + { + "slug": "altern", + "name": "Altern", + "type": "directory", + "website_url": "https://altern.ai/", + "submission_url": "https://altern.ai/maker/submit/start", + "logo_url": "https://www.google.com/s2/favicons?domain=altern.ai&sz=64", + "description": "Altern is a curated AI directory and marketplace for tools, agents, models, and APIs with a Maker submission path.", + "audience": "Makers of AI tools, agents, models, and APIs seeking curated directory and marketplace discovery.", + "eligibility": "Makers may submit AI tools, models, or APIs they have the right to list; submissions are reviewed and may be rejected or removed when misleading, spammy, or against the terms.", + "pricing": "free", + "pricing_note": "Some listing tiers are paid through PayPal, while the public site does not publish a complete official price sheet.", + "link_type": "dofollow", + "domain_rating": 46, + "monthly_traffic": 1500, + "categories": [ + "ai", + "saas", + "startup" + ] + }, + { + "slug": "alternative-me", + "name": "Alternative.me", + "type": "directory", + "website_url": "https://alternative.me/", + "submission_url": "https://alternative.me/how-to/submit-software/", + "logo_url": "https://www.google.com/s2/favicons?domain=alternative.me&sz=64", + "description": "Alternative.me is a platform for discovering and comparing software, apps, products, and their alternatives.", + "audience": "Software and product makers who want to add a public product entry for comparison and alternative discovery.", + "eligibility": "Anyone with a user account can submit a software or product entry through the account dashboard.", + "pricing": "free", + "pricing_note": "Software submission is free with a user account.", + "link_type": "dofollow", + "domain_rating": 75, + "monthly_traffic": 106500, + "categories": [ + "indie", + "productivity" + ] + }, + { + "slug": "alternativeto", + "name": "AlternativeTo", + "type": "directory", + "website_url": "https://alternativeto.net", + "submission_url": "https://alternativeto.net/manage-item/", + "logo_url": "https://alternativeto.net/static/icons/a2/apple-touch-icon.png", + "description": "Crowdsourced software directory that recommends alternatives to apps you already know, across desktop, mobile, and web.", + "audience": "People comparing software and searching for alternatives to well-known apps", + "eligibility": "Software or apps with a public website; submitting requires a free account.", + "pricing": "free", + "pricing_note": "Adding an app is free; a free AlternativeTo account is required.", + "link_type": "nofollow", + "domain_rating": 80, + "monthly_traffic": 114300, + "categories": [ + "developer-tools", + "productivity", + "saas" + ] + }, + { + "slug": "android-central", + "name": "Android Central", + "type": "citation", + "website_url": "https://www.androidcentral.com/", + "submission_url": "https://forums.androidcentral.com/threads/app-and-developer-forum-rules-guidelines.827832/", + "logo_url": "https://www.google.com/s2/favicons?domain=androidcentral.com&sz=64", + "description": "Android Central is a technology publication and community covering Android devices, apps, news, reviews, and the wider Google ecosystem.", + "audience": "Android app and game developers who can provide a real product announcement and support users in the community.", + "eligibility": "App and game developers may create one thread per application with a working app-store link, screenshot or video, developer disclosure, and ongoing user support.", + "pricing": "free", + "pricing_note": "Developer forum participation and editorial tips are free; separate Best in Show opportunities may be paid.", + "link_type": "nofollow", + "domain_rating": 83, + "monthly_traffic": 391200, + "categories": [ + "community", + "developer-tools" + ] + }, + { + "slug": "android-headlines", + "name": "Android Headlines", + "type": "citation", + "website_url": "https://www.androidheadlines.com/", + "submission_url": "https://www.androidheadlines.com/news-tips", + "logo_url": "https://www.google.com/s2/favicons?domain=androidheadlines.com&sz=64", + "description": "Android Headlines is an independent Android and technology news site covering phones, tablets, apps, reviews, and industry news.", + "audience": "Android and technology companies with a genuinely newsworthy product, update, tip, or exclusive relevant to the publication's audience.", + "eligibility": "Editorial coverage depends on an interesting, newsworthy Android or technology tip, product, or exclusive and independent editor selection; advertising is a separate paid route.", + "pricing": "free", + "pricing_note": "News tips are free when selected for coverage; advertising campaigns are paid separately with terms available by inquiry.", + "link_type": "dofollow", + "domain_rating": 77, + "monthly_traffic": 142700, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "angi", + "name": "Angi", + "type": "marketplace", + "website_url": "https://www.angi.com", + "submission_url": "https://signup.angi.com/pro", + "logo_url": "https://www.google.com/s2/favicons?domain=angi.com&sz=64", + "description": "Angi is a home-services marketplace where service professionals can create profiles, receive reviews, and generate leads.", + "audience": "Home-service businesses with a real local service offering, accurate business details, and any required licenses.", + "eligibility": "A business must be a legitimate home-service provider; Angi may verify business details, licenses, reviews, and background-check requirements for enhanced status.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 90, + "monthly_traffic": 1000000, + "categories": [ + "business" + ] + }, + { + "slug": "app-launcher", + "name": "AppLauncher", + "type": "community", + "website_url": "https://www.applauncher.io/", + "submission_url": "https://www.applauncher.io/auth/register", + "logo_url": "https://www.applauncher.io/static/images/app-launcher-favicon/apple-touch-icon.png", + "description": "Product-launch platform and launch feed for indie developers and startups, with listing, brand-kit, SEO, audience, analytics, and multi-directory submission tools.", + "audience": "Indie developers and startup teams preparing to launch and promote SaaS products, apps, tools, and online services.", + "eligibility": "A free account may submit a genuine software product, SaaS, app, tool, or service to the public launch feed. Prepare accurate launch and creator details and avoid duplicate or misleading listings.", + "pricing": "free", + "pricing_note": "A standard launch-feed listing is free. Optional upgrades include a $14 seven-day featured listing, $19 next-period priority, a $5 permanent dofollow link, three relaunch credits for $9, and advertising from $45.", + "link_type": "unknown", + "domain_rating": 14, + "monthly_traffic": 6700, + "categories": [ + "community", + "indie", + "saas", + "startup" + ] + }, + { + "slug": "appagg", + "name": "AppAgg", + "type": "directory", + "website_url": "https://appagg.com/", + "submission_url": "https://appagg.com/add/", + "logo_url": "https://www.google.com/s2/favicons?domain=appagg.com&sz=64", + "description": "AppAgg is an independent app aggregator that indexes apps and games from major mobile, desktop, and console stores for discovery, deals, rankings, and lists.", + "audience": "Publishers of apps and games already available in supported official stores.", + "eligibility": "Submissions must be apps or games published on a supported official store and use a valid store URL for add, update, or removal requests.", + "pricing": "free", + "pricing_note": "Grok reported free listing submission.", + "link_type": "dofollow", + "domain_rating": 50, + "monthly_traffic": 3800, + "categories": [ + "developer-tools", + "startup" + ] + }, + { + "slug": "appbusiness", + "name": "r/AppBusiness", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/AppBusiness/", + "submission_url": "https://www.reddit.com/r/AppBusiness/submit", + "logo_url": "https://styles.redditmedia.com/t5_30d3m/styles/communityIcon_9sgrpkvedl341.jpeg?width=256&s=116b21ff314357119cb63037dd9aeb872c531084", + "description": "A community for app founders, indie hackers, developers, and marketers building, launching, and growing app businesses. Join our free list for upcoming AMAs, workshops, meetups, demo days, and networking events: [appbusiness.club](https://appbusiness.club/)\n\nTopics for discussion:\n- App Marketing\n- App Store Optimization (ASO)\n- Monetization\n- User Acquisition\n- Ideation\n- Outsourcing\n- Testing & Optimization", + "audience": "App founders, indie hackers, developers, and marketers working on app businesses.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "business", + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 142168, + "growth_30d_absolute": 6140, + "promotion_mode": "promotion_thread", + "rules_url": "https://www.reddit.com/r/AppBusiness/about/rules" + } + }, + { + "slug": "appgiveaway", + "name": "r/AppGiveaway", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/AppGiveaway/", + "submission_url": "https://www.reddit.com/r/AppGiveaway/submit", + "logo_url": "https://styles.redditmedia.com/t5_cpktjf/styles/communityIcon_ezf102kg2qvd1.png?width=256&s=3dc29a12d3e7bb35401c642e43d479552581fd06", + "description": "Get paid apps for free daily. IOS & Android apps & games for free. AppsGoneFree", + "audience": "App makers and users sharing qualifying free app deals.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 31618, + "growth_30d_absolute": 1655, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/AppGiveaway/about/rules" + } + }, + { + "slug": "appideas", + "name": "r/AppIdeas", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/AppIdeas/", + "submission_url": "https://www.reddit.com/r/AppIdeas/submit", + "logo_url": "https://styles.redditmedia.com/t5_2sa1z/styles/communityIcon_iugd05oyb31e1.png?width=256&s=d822e20116f913e6589b51376f082099420e3b28", + "description": "Welcome! This subreddit is designed to be a professional environment where developers, entrepreneurs, and creators can come together to share their amazing app ideas. Please take a moment to look over our rules before posting.", + "audience": "Developers, entrepreneurs, and creators discussing and refining app ideas.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "community", + "developer-tools", + "startup" + ], + "subreddit": { + "members_count": 101450, + "growth_30d_absolute": 2379, + "promotion_mode": "prohibited", + "rules_url": "https://www.reddit.com/r/AppIdeas/about/rules" + } + }, + { + "slug": "appscribed", + "name": "Appscribed", + "type": "directory", + "website_url": "https://appscribed.com/", + "submission_url": "https://appscribed.com/submit-ai-tool/", + "logo_url": "https://www.google.com/s2/favicons?domain=appscribed.com&sz=64", + "description": "Appscribed is an AI tools directory with curated listings, hand-written editorial reviews, and comparisons.", + "audience": "Makers of AI tools seeking curated directory discovery and editorial review.", + "eligibility": "Listings must be AI tools that meet Appscribed's editorial evaluation criteria.", + "pricing": "paid", + "pricing_note": "Permanent listing and editorial review cost $49 one time; optional boosts cost $99 for seven days or $199 for 30 days, with a refund if rejected.", + "link_type": "dofollow", + "domain_rating": 42, + "monthly_traffic": 1700, + "categories": [ + "ai", + "saas" + ] + }, + { + "slug": "appsumo", + "name": "AppSumo", + "type": "marketplace", + "website_url": "https://appsumo.com", + "submission_url": "https://partners.appsumo.com/self-submission", + "logo_url": "https://appsumo.com/favicon.ico", + "description": "Curated marketplace where established software companies launch deals to an entrepreneur audience.", + "audience": "Working software companies serving entrepreneurs, small businesses, marketers, creators, and agencies.", + "eligibility": "Serious, working software companies with a stable business, a product for entrepreneurs or small businesses, and capacity to support customers. Pre-launch, vaporware, consumer-only products, and quick cash grabs are explicitly not a fit.", + "pricing": "free", + "pricing_note": "No listing fee; AppSumo takes revenue-share commissions on sales.", + "link_type": "nofollow", + "domain_rating": 83, + "monthly_traffic": 96400, + "categories": [ + "business", + "marketing", + "saas", + "startup" + ] + }, + { + "slug": "appvizer", + "name": "Appvizer", + "type": "directory", + "website_url": "https://www.appvizer.com/", + "submission_url": "https://help.appvizer.com/en/articles/how-do-i-reference-software-on-appvizer", + "logo_url": "https://www.google.com/s2/favicons?domain=appvizer.com&sz=64", + "description": "Appvizer is a B2B software comparison, catalog, and media platform that helps professionals discover, compare, and select SaaS and business tools across many categories and languages.", + "audience": "B2B software and SaaS vendors seeking a reviewed product profile for business-software discovery and comparison.", + "eligibility": "Eligible software needs a well-structured, original, objective product sheet with at least 500 non-duplicated description characters, a title of 20–60 characters, and at least one supported feature; profiles publish after Appvizer's quality review.", + "pricing": "free", + "pricing_note": "Basic software referencing is free and without commitment; paid campaigns and lead-generation options are separate.", + "link_type": "dofollow", + "domain_rating": 51, + "monthly_traffic": 16100, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "atlassian-marketplace", + "name": "Atlassian Marketplace", + "type": "marketplace", + "website_url": "https://marketplace.atlassian.com", + "submission_url": "https://marketplace.atlassian.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=marketplace.atlassian.com&sz=64", + "description": "Atlassian Marketplace lists third-party apps that integrate with and extend Jira, Confluence, and other Atlassian products.", + "audience": "Developers of public apps that extend Atlassian products and can meet Marketplace approval requirements.", + "eligibility": "Apps need a public compatible implementation, HTTPS descriptor, required privacy and legal documents, policy compliance, and Marketplace approval.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 92, + "monthly_traffic": 100700, + "categories": [ + "developer-tools", + "productivity" + ] + }, + { + "slug": "aura-plus-plus", + "name": "Aura++", + "type": "product-launch-site", + "website_url": "https://auraplusplus.com/", + "submission_url": "https://auraplusplus.com/projects/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=auraplusplus.com&sz=64", + "description": "Aura++ is a product-launch platform where founders schedule daily launches, collect community upvotes, and receive SEO assets such as structured pages, backlinks, and launch stories.", + "audience": "Founders of startups and digital or technology products that can be submitted with product details, categories, and a launch date.", + "eligibility": "Eligible submissions are startups and digital or technology products with the details, categories, and launch date required by the submission flow.", + "pricing": "free", + "pricing_note": "Free and no-follow tiers cost US$0; Premium costs US$29 and Premium Plus costs US$69, with stronger promotional and link benefits.", + "link_type": "dofollow", + "domain_rating": 72, + "monthly_traffic": 5300, + "categories": [ + "indie", + "startup" + ] + }, + { + "slug": "awwwards", + "name": "Awwwards", + "type": "directory", + "website_url": "https://www.awwwards.com", + "submission_url": "https://www.awwwards.com/submit/", + "logo_url": "https://www.google.com/s2/favicons?domain=awwwards.com&sz=64", + "description": "Awwwards is a web-design awards platform where live websites are submitted for staff review and jury or community evaluation.", + "audience": "Owners and creators of original, live websites with design and development quality suited to Awwwards evaluation.", + "eligibility": "Submissions must be live websites with original design and development; pre-made templates are not accepted except eligible creator-owned demos.", + "pricing": "paid", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 89, + "monthly_traffic": 729000, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "b2b-stack", + "name": "B2B Stack", + "type": "marketplace", + "website_url": "https://www.b2bstack.com.br/", + "submission_url": "https://www.b2bstack.com.br/produto/novo-item", + "logo_url": "https://www.google.com/s2/favicons?domain=b2bstack.com.br&sz=64", + "description": "B2B Stack is a Brazilian B2B software search and review marketplace for product discovery and vendor presence.", + "audience": "B2B software and service vendors serving Brazilian and Latin American buyers.", + "eligibility": "B2B software and services can be suggested with a name, site, category, and description; staff review suggestions before publication, while vendor presence plans are for companies seeking category pages and reviews.", + "pricing": "free", + "pricing_note": "The product-suggestion and basic Vitrine Digital paths are free; premium vendor-presence plans are paid or quoted separately.", + "link_type": "nofollow", + "domain_rating": 59, + "monthly_traffic": 21300, + "categories": [ + "business", + "marketing", + "saas" + ] + }, + { + "slug": "b2bsaas", + "name": "r/B2BSaaS", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/B2BSaaS/", + "submission_url": "https://www.reddit.com/r/B2BSaaS/submit", + "logo_url": "https://styles.redditmedia.com/t5_303xb/styles/communityIcon_ceacm82ejenf1.png?width=256&s=9f3915113860aabdf8eb31532ad0ec1f85a34eae", + "description": "This subreddit is directed at anyone involved in a B2B SaaS business.\n\nTypical topics include conversions, retention, churn, metrics, analytics, engagement, inbound marketing, distribution channels and so on.\n\nShare advice, growth hacks, links, questions or personal stories that people who are working on B2B SaaS products can relate to.", + "audience": "Founders and operators building or working with B2B SaaS products.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "saas", + "startup" + ], + "subreddit": { + "members_count": 28691, + "growth_30d_absolute": 1844, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/B2BSaaS/about/rules" + } + }, + { + "slug": "behance", + "name": "Behance", + "type": "community", + "website_url": "https://www.behance.net", + "submission_url": "https://www.behance.net", + "logo_url": "https://www.google.com/s2/favicons?domain=behance.net&sz=64", + "description": "Adobe-owned platform where creatives and companies showcase portfolios of design, illustration, photography, motion, and related work for discovery, feedback, and opportunities.", + "audience": "Creatives and companies showcasing design, illustration, photography, and motion portfolios.", + "eligibility": "Anyone with a free Behance account can create and publish projects; teams/companies can create a Team portfolio and associate projects with it.", + "pricing": "free", + "pricing_note": "Free to create an account and publish projects.", + "link_type": "nofollow", + "domain_rating": 94, + "monthly_traffic": 3600000, + "categories": [ + "design" + ] + }, + { + "slug": "bens-bites", + "name": "Ben's Bites", + "type": "citation", + "website_url": "https://www.bensbites.com/", + "submission_url": "https://catalog.bensbites.com/partners", + "logo_url": "https://www.google.com/s2/favicons?domain=bensbites.com&sz=64", + "description": "Ben's Bites is a newsletter and related community and catalog covering AI tools, launches, startups, investing, news, and reviews for AI builders.", + "audience": "AI tools, startups, launches, and resources with a factual, audience-relevant angle for AI builders, founders, and operators.", + "eligibility": "Free editorial mentions depend on independent editorial judgment and relevance to the AI-curious professional audience; paid placements must fit the publication's audience and partnership offerings.", + "pricing": "free", + "pricing_note": "Editorial coverage is free only when independently selected; paid sponsorship and guaranteed placement packages are available, with primary placements around US$3,500 and lower-cost options for other placements.", + "link_type": "nofollow", + "domain_rating": 63, + "monthly_traffic": 1500, + "categories": [ + "ai", + "startup" + ] + }, + { + "slug": "bestof-ai", + "name": "BestofAI", + "type": "directory", + "website_url": "https://bestofai.com/", + "submission_url": "https://bestofai.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=bestofai.com&sz=64", + "description": "BestofAI is a curated AI-tools directory with more than 15,000 products across more than 100 categories.", + "audience": "Makers of AI tools and apps seeking curated directory discovery.", + "eligibility": "Listings must be AI tools or apps; contributions cannot be false, spam, or unauthorized advertising, and the operator may reject or remove listings.", + "pricing": "free", + "pricing_note": "The basic Add Tool path is presented as a free account action; featured sponsor placement is published at $250 per month.", + "link_type": "dofollow", + "domain_rating": 42, + "monthly_traffic": 5800, + "categories": [ + "ai", + "saas" + ] + }, + { + "slug": "betakit", + "name": "BetaKit", + "type": "citation", + "website_url": "https://betakit.com/", + "submission_url": "https://betakit.com/contact-us/", + "logo_url": "https://www.google.com/s2/favicons?domain=betakit.com&sz=64", + "description": "BetaKit is a Canadian publication covering startup and technology innovation news.", + "audience": "Canadian startups and technology companies with a relevant product, funding, partnership, or innovation story and evidence of traction.", + "eligibility": "Editorial stories must concern Canadian startup or technology innovation and offer a timely, relevant angle; traction is preferred and editorial coverage is not guaranteed.", + "pricing": "free", + "pricing_note": "Editorial pitches are free when accepted; advertising and partnership options are separately paid.", + "link_type": "dofollow", + "domain_rating": 78, + "monthly_traffic": 11700, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "betalist", + "name": "BetaList", + "type": "product-launch-site", + "website_url": "https://betalist.com", + "submission_url": "https://betalist.com/submit", + "logo_url": "https://betalist.com/assets/touch-icon-precomposed-144-e8598d79.png", + "description": "Curated showcase of upcoming internet startups where early adopters discover and get early access to new products before launch.", + "audience": "Early adopters and makers discovering pre-launch and newly launched startups", + "eligibility": "Recently launched or unreleased technology startups with a distinct landing page; no agencies, blogs, newsletters, templates, or free hosting subdomains.", + "pricing": "paid", + "pricing_note": "All submissions are paid; plans and prices are shown at the end of the submission form. If your startup isn't selected, the payment is refunded in full automatically.", + "link_type": "dofollow", + "domain_rating": 76, + "monthly_traffic": 18300, + "categories": [ + "indie", + "startup" + ] + }, + { + "slug": "betatesting", + "name": "BetaTesting", + "type": "marketplace", + "website_url": "https://betatesting.com/", + "submission_url": "https://betatesting.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=betatesting.com&sz=64", + "description": "BetaTesting connects companies with a panel of beta testers for real-world product testing, feedback, and research.", + "audience": "Companies seeking real-user testing for apps, websites, hardware, or prototypes.", + "eligibility": "Companies may run tests for apps, websites, hardware, or prototypes using the recruited tester panel; it is not a public product-directory listing.", + "pricing": "paid", + "pricing_note": "Project credits cost about $23–$39 each, with discounted subscriptions and enterprise plans; limited free credits may be available through qualifying offers.", + "link_type": "nofollow", + "domain_rating": 55, + "monthly_traffic": 6200, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "betatests", + "name": "r/betatests", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/betatests/", + "submission_url": "https://www.reddit.com/r/betatests/submit", + "logo_url": "https://styles.redditmedia.com/t5_2vu3w/styles/communityIcon_eul3vg7jn7jf1.png?width=256&s=acfe38a9992a10cffc832f33dcfbe8b1cb709e32", + "description": "🚀 New beta opportunities daily!\n\n✅ Makers: post your product and get real feedback. All types welcome.\n\n💬 Join our Discord using the button below 👇", + "audience": "Product makers seeking beta users and constructive feedback, alongside community members looking for new beta opportunities.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "community", + "developer-tools", + "startup" + ], + "subreddit": { + "members_count": 16503, + "growth_30d_absolute": 1038, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/betatests/about/rules" + } + }, + { + "slug": "better-business-bureau", + "name": "Better Business Bureau", + "type": "directory", + "website_url": "https://www.bbb.org", + "submission_url": "https://www.bbb.org/get-listed", + "logo_url": "https://www.google.com/s2/favicons?domain=bbb.org&sz=64", + "description": "The Better Business Bureau operates a public directory of business profiles with ratings, complaint history, and reviews, plus a voluntary paid Accreditation program.", + "audience": "Businesses that want a public BBB profile; Accreditation is for established businesses that meet BBB trust standards.", + "eligibility": "A business can request or claim a free profile. Accreditation requires at least six months in business, a qualifying track record, resolved complaints, required licenses, and BBB review against its Standards for Trust.", + "pricing": "free", + "pricing_note": "A basic Business Profile is free. Accreditation requires annual dues that vary by local BBB and business size, and may include an application fee.", + "link_type": "nofollow", + "domain_rating": 93, + "monthly_traffic": 3500000, + "categories": [ + "business" + ] + }, + { + "slug": "biggerpockets", + "name": "BiggerPockets", + "type": "community", + "website_url": "https://www.biggerpockets.com/", + "submission_url": "https://www.biggerpockets.com/signup", + "logo_url": "https://www.google.com/s2/favicons?domain=biggerpockets.com&sz=64", + "description": "Real-estate investing community with public forums, member profiles, educational articles, podcasts, tools, events, and property-related discussions.", + "audience": "Real-estate investors, landlords, agents, lenders, property professionals, and people learning to buy, finance, operate, or sell real estate.", + "eligibility": "Anyone may join, but project or service mentions must be directly useful to a real-estate discussion and comply with community rules; unrelated SaaS promotion is not eligible.", + "pricing": "free", + "pricing_note": "BiggerPockets offers free membership for community access; optional paid membership tiers and products are separate.", + "link_type": "unknown", + "domain_rating": 80, + "monthly_traffic": 54300, + "categories": [ + "business", + "community" + ] + }, + { + "slug": "bing-places", + "name": "Bing Places for Business", + "type": "citation", + "website_url": "https://www.bing.com/forbusiness", + "submission_url": "https://www.bing.com/forbusiness/help/createYourListing", + "logo_url": "https://www.google.com/s2/favicons?domain=bing.com&sz=64", + "description": "Bing Places for Business is Microsoft's free platform for businesses to create or claim listings that appear in Bing Search, Bing Maps, and related surfaces.", + "audience": "Local and service-area businesses that can provide accurate business identity, address or service area, contact details, and ownership verification.", + "eligibility": "Businesses need a valid location or service area and must complete phone, email, or postcard verification; a purely digital product without a local or service-area business presence is not eligible.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 93, + "monthly_traffic": 17700000, + "categories": [ + "business", + "community" + ] + }, + { + "slug": "bostinno", + "name": "BostInno", + "type": "citation", + "website_url": "https://www.bizjournals.com/boston/inno", + "submission_url": "https://www.bizjournals.com/boston/nomination/89338/2026/2026-bostinno-the-fire-awards", + "logo_url": "https://www.google.com/s2/favicons?domain=bizjournals.com&sz=64", + "description": "BostInno is the Boston Business Journal section covering Boston-area startups, technology news, events, and innovation.", + "audience": "Companies and organizations making a meaningful contribution to the Boston innovation ecosystem.", + "eligibility": "BostInno Fire Award nominees must be innovators in the Boston ecosystem; staff review nominations and a judges panel selects honorees by category and impact.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 91, + "monthly_traffic": 381400, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "brouseai", + "name": "BroUseAI", + "type": "directory", + "website_url": "https://www.brouseai.com/", + "submission_url": "https://www.brouseai.com/submission/ai", + "logo_url": "https://www.google.com/s2/favicons?domain=brouseai.com&sz=64", + "description": "AI tool directory for discovering categorized products, featured tools, new releases, pricing models, use cases, and AI terminology.", + "audience": "People evaluating AI products for work, learning, productivity, media creation, and everyday use.", + "eligibility": "The directory accepts public AI products but excludes explicit or offensive material, gambling, graphic violence, discrimination, illegal activity, substance abuse, and harmful misinformation.", + "pricing": "free", + "pricing_note": "Basic submission is free and enters the review queue; the optional Premium plan is a one-time $29 plus VAT and advertises review within 48 hours and additional promotion.", + "link_type": "dofollow", + "domain_rating": 10, + "monthly_traffic": 37500, + "categories": [ + "ai", + "productivity" + ] + }, + { + "slug": "brownbook", + "name": "Brownbook", + "type": "directory", + "website_url": "https://www.brownbook.net/", + "submission_url": "https://www.brownbook.net/register/", + "logo_url": "https://www.google.com/s2/favicons?domain=brownbook.net&sz=64", + "description": "Brownbook is a free, editable global business listing database used for business discovery and local-search citations.", + "audience": "Businesses that can publish and maintain accurate public company, location, category, and contact details.", + "eligibility": "Any legitimate business can register to add a listing or claim an existing profile, provided the profile uses accurate business information.", + "pricing": "free", + "pricing_note": "Listing creation, profile claims, and enhanced Profile+ accounts are free.", + "link_type": "dofollow", + "domain_rating": 79, + "monthly_traffic": 3500, + "categories": [ + "business" + ] + }, + { + "slug": "buildinpublic", + "name": "r/buildinpublic", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/buildinpublic/", + "submission_url": "https://www.reddit.com/r/buildinpublic/submit", + "logo_url": "https://styles.redditmedia.com/t5_34ital/styles/communityIcon_8soma9yaptef1.png?width=256&s=0e32956c2c5ad72e9fade29a30c8323787173bfb", + "description": "A community for creators, developers, entrepreneurs, and makers to openly share their journey as they build projects in public. Whether you're coding, designing, writing, or launching a business, this is the place to share progress, challenges, wins, and lessons.", + "audience": "Makers, developers, and founders sharing product progress, lessons, and feedback requests.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "indie", + "startup" + ], + "subreddit": { + "members_count": 115448, + "growth_30d_absolute": 4191, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/buildinpublic/about/rules" + } + }, + { + "slug": "built-in", + "name": "Built In", + "type": "directory", + "website_url": "https://builtin.com", + "submission_url": "https://knowledgebase.builtin.com/s/article/How-do-I-create-a-company-profile", + "logo_url": "https://www.google.com/s2/favicons?domain=builtin.com&sz=64", + "description": "Built In is a technology employer-brand platform where companies create profiles, showcase workplace details, and post jobs.", + "audience": "Technology employers with a verifiable company profile and hiring or employer-brand needs in Built In's supported markets.", + "eligibility": "Companies must create or claim a verified profile; enhanced employer-brand and recruiting features require a customer relationship with Built In.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 86, + "monthly_traffic": 948900, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "built-in-chicago", + "name": "Built In Chicago", + "type": "directory", + "website_url": "https://www.builtinchicago.org/", + "submission_url": "https://knowledgebase.builtin.com/s/article/How-do-I-claim-my-company-profile", + "logo_url": "https://www.google.com/s2/favicons?domain=builtinchicago.org&sz=64", + "description": "Built In Chicago is Built In's Chicago tech and startup vertical, with company profiles, job listings, local tech news, and employer-branding resources.", + "audience": "Chicago-focused technology and startup companies seeking a company profile within the Built In network.", + "eligibility": "Companies need an existing profile to claim or verified administrator access to create and manage a company profile; paid employer membership is required for enhanced features.", + "pricing": "free", + "pricing_note": "Basic company-profile claim or creation is free; paid employer membership adds job posting, employer branding, and other enhanced features.", + "link_type": "nofollow", + "domain_rating": 72, + "monthly_traffic": 79900, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "business-ideas", + "name": "r/Business_Ideas", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/Business_Ideas/", + "submission_url": "https://www.reddit.com/r/Business_Ideas/submit", + "logo_url": "https://styles.redditmedia.com/t5_2wk90/styles/communityIcon_kzsu05vh28h61.png?width=256&s=f0e4d9cfc7b77bc93d19b13936eb72e512650e6c", + "description": "Share and explore innovative business ideas, gain insights on initiating your venture, unravel the intricacies of 'Cost of Sale' (CoS), and decode the essentials of taxation – your nexus for entrepreneurial minds seeking to transform concepts into successful ventures.", + "audience": "Founders and entrepreneurs exploring business ideas and asking for feedback.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "business", + "indie", + "startup" + ], + "subreddit": { + "members_count": 470497, + "growth_30d_absolute": 6756, + "promotion_mode": "promotion_thread", + "rules_url": "https://www.reddit.com/r/Business_Ideas/about/rules" + } + }, + { + "slug": "business-software", + "name": "Business-Software.com", + "type": "directory", + "website_url": "https://www.business-software.com/", + "submission_url": "https://www.business-software.com/add-your-product/", + "logo_url": "https://www.business-software.com/favicon.ico", + "description": "Business-Software.com is a business software review, comparison, and directory site for technology buyers.", + "audience": "Makers of business software and IT solutions seeking discovery by technology buyers.", + "eligibility": "Listings must be business software or IT solutions; submitters complete the product form and work with the site to finish the listing.", + "pricing": "free", + "pricing_note": "A basic product profile is free; paid review, lead, and featured options are available.", + "link_type": "nofollow", + "domain_rating": 51, + "monthly_traffic": 9100, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "businessdeconstructed", + "name": "r/BusinessDeconstructed", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/BusinessDeconstructed/", + "submission_url": "https://www.reddit.com/r/BusinessDeconstructed/submit", + "logo_url": "https://styles.redditmedia.com/t5_fkwfm7/styles/communityIcon_is036ouunwng1.jpg?width=256&s=d58cd3e5fbf3e4dc53617b95320e5100291b1225", + "description": "Business Deconstructed is for entrepreneurs and anyone interested in learning the how behind building and growing a business. It was made to learn, share ideas, and get feedback from fellow entrepreneurs.", + "audience": "Entrepreneurs sharing practical business advice and useful resources.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "business", + "startup" + ], + "subreddit": { + "members_count": 22026, + "growth_30d_absolute": 1582, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/BusinessDeconstructed/about/rules" + } + }, + { + "slug": "businesswith", + "name": "BusinessWith", + "type": "marketplace", + "website_url": "https://www.businesswith.com/", + "submission_url": "https://hub.businesswith.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=businesswith.com&sz=64", + "description": "European B2B software marketplace that helps buyers compare software and connects SaaS suppliers with prospective customers.", + "audience": "B2B SaaS suppliers seeking discovery and qualified buyer leads across the European software market.", + "eligibility": "BusinessWith invites SaaS suppliers to list their software through its supplier hub. The team reviews the vendor inquiry before any commercial partnership or listing is arranged.", + "pricing": "unknown", + "pricing_note": "The supplier hub does not publish listing prices. It asks vendors to contact the team to discuss how the marketplace can help.", + "link_type": "unknown", + "domain_rating": 48, + "monthly_traffic": 1300, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "capterra", + "name": "Capterra", + "type": "directory", + "website_url": "https://www.capterra.com", + "submission_url": "https://app.g2digitalmarkets.com/get-listed/start", + "logo_url": "https://www.google.com/s2/favicons?domain=capterra.com&sz=64", + "description": "B2B software discovery and review platform where vendors create product listings for in-market buyers.", + "audience": "Businesses researching, comparing, and buying software; software vendors seeking buyer discovery and reviews.", + "eligibility": "B2B software vendors seeking a product listing and buyer discovery through Capterra/G2 Digital Markets.", + "pricing": "free", + "pricing_note": "Basic listing is free; sponsored/PPC visibility is optional.", + "link_type": "dofollow", + "domain_rating": 91, + "monthly_traffic": 407500, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "chrome-web-store", + "name": "Chrome Web Store", + "type": "marketplace", + "website_url": "https://chromewebstore.google.com", + "submission_url": "https://chrome.google.com/webstore/devconsole", + "logo_url": "https://www.google.com/s2/favicons?domain=chromewebstore.google.com&sz=64", + "description": "Google's official online marketplace where users discover, install, and manage Chrome browser extensions, themes, and related items.", + "audience": "Chrome browser users discovering extensions/themes, and developers publishing them.", + "eligibility": "Requires a valid Google account registered as a Chrome Web Store developer (one-time $5 registration fee); the item must be a compliant ZIP with a valid manifest.json and complete store-listing and privacy disclosures. New publishers face default limits (around 2 published extensions, raisable with tenure/quality).", + "pricing": "paid", + "pricing_note": "One-time $5 developer registration fee; publishing itself is free after that, subject to review.", + "link_type": "dofollow", + "domain_rating": 99, + "monthly_traffic": 19800000, + "categories": [ + "developer-tools", + "productivity" + ] + }, + { + "slug": "clutch", + "name": "Clutch", + "type": "directory", + "website_url": "https://clutch.co", + "submission_url": "https://clutch.co/get-listed", + "logo_url": "https://www.google.com/s2/favicons?domain=clutch.co&sz=64", + "description": "Clutch is a B2B ratings-and-reviews platform that lists and ranks professional service providers using verified client feedback.", + "audience": "B2B professional service providers in technology, development, design, marketing, and related fields.", + "eligibility": "Providers need a functional website, verifiable full-time employees, and a service offering that fits Clutch's B2B categories; Clutch reviews profiles before publishing.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 91, + "monthly_traffic": 1300000, + "categories": [ + "business", + "developer-tools" + ] + }, + { + "slug": "cnet-download-com", + "name": "CNET Download.com", + "type": "directory", + "website_url": "https://download.cnet.com", + "submission_url": "https://download.cnet.com/add-your-software/", + "logo_url": "https://www.google.com/s2/favicons?domain=download.cnet.com&sz=64", + "description": "CNET Download.com is CNET's software-distribution directory for apps and programs.", + "audience": "Software makers with an app or program that is ready for distribution.", + "eligibility": "The submitted software must be ready for distribution and include the information requested by CNET's official add-software form.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 91, + "monthly_traffic": 1200000, + "categories": [ + "developer-tools", + "saas" + ] + }, + { + "slug": "cofounderslab", + "name": "CoFoundersLab", + "type": "community", + "website_url": "https://cofounderslab.com/", + "submission_url": "https://cofounderslab.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=cofounderslab.com&sz=64", + "description": "CoFoundersLab is an online networking community that connects entrepreneurs with potential cofounders, advisors, and investors through profiles and matching tools.", + "audience": "Entrepreneurial founders seeking cofounders, advisors, team members, or investors for their startup.", + "eligibility": "Entrepreneurial individuals and founders can create a profile describing their skills, interests, and startup to appear in member searches and matching.", + "pricing": "free", + "pricing_note": "Basic membership is free with limited searches and messaging; Premium is about $29 per month for priority search placement, unlimited messaging, recommendations, and events.", + "link_type": "nofollow", + "domain_rating": 62, + "monthly_traffic": 2300, + "categories": [ + "community", + "startup" + ] + }, + { + "slug": "comparably", + "name": "Comparably", + "type": "directory", + "website_url": "https://www.comparably.com/", + "submission_url": "https://www.comparably.com/company-tools", + "logo_url": "https://www.google.com/s2/favicons?domain=comparably.com&sz=64", + "description": "Employer and company-profile platform combining verified employee reviews, workplace-culture ratings, salary data, company Q&A, employer-brand tools, recruiting content, and awards.", + "audience": "Employers and people teams managing a public employer brand, recruiting reputation, employee-review presence, and workplace-culture visibility.", + "eligibility": "A legitimate company representative may sign up and begin the company-claim process. Employer tools are intended for organizations that can verify their relationship to the company and provide accurate culture and brand information.", + "pricing": "free", + "pricing_note": "Account signup and the company-claim flow are publicly available; enhanced employer-brand and recruiting tools use a contact-sales process and do not publish fixed pricing.", + "link_type": "unknown", + "domain_rating": 79, + "monthly_traffic": 149200, + "categories": [ + "business" + ] + }, + { + "slug": "contentrich", + "name": "r/ContentRich", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/ContentRich/", + "submission_url": "https://www.reddit.com/r/ContentRich/submit", + "logo_url": "https://styles.redditmedia.com/t5_gflmjj/styles/communityIcon_mcw8j99sdegg1.png?width=256&s=646ffc5c76cee7af487d433e20fc23d2496429fb", + "description": "Get rich making content\nUGC Platform Bounty CODE to get approved fast : 0I5PPIH3", + "audience": "Content creators interested in UGC platform opportunities.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "community", + "marketing" + ], + "subreddit": { + "members_count": 10843, + "growth_30d_absolute": 564, + "promotion_mode": "restricted", + "rules_url": "https://www.reddit.com/r/ContentRich/about/rules" + } + }, + { + "slug": "crowdreviews", + "name": "CrowdReviews.com", + "type": "directory", + "website_url": "https://www.crowdreviews.com/", + "submission_url": "https://www.crowdreviews.com/verify/get-started", + "logo_url": "https://www.google.com/s2/favicons?domain=crowdreviews.com&sz=64", + "description": "CrowdReviews.com is a community-driven platform that ranks software and services using user reviews and a transparent algorithm.", + "audience": "Software and service providers seeking a public profile and customer-review presence.", + "eligibility": "Eligible products and services can receive user reviews; verified status requires attorney or CPA confirmation of key business facts.", + "pricing": "free", + "pricing_note": "Basic registration or profile claim appears free; paid verification and traffic-promotion options are also available.", + "link_type": "nofollow", + "domain_rating": 66, + "monthly_traffic": 3700, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "crozdesk", + "name": "Crozdesk", + "type": "directory", + "website_url": "https://crozdesk.com", + "submission_url": "https://vendor.revleads.com/user/signup", + "logo_url": "https://assets.crozdesk.com/assets/favicon-d78d66a9d814d4c4ec75a1e794895d9e5542b2c750fa1621d87d6522f767ff28.ico", + "description": "Business software search platform that helps buyers find and compare software across all major categories with rankings, reviews, and pricing.", + "audience": "Businesses searching for and comparing software across all major categories", + "eligibility": "Software products; vendors apply through the Revleads vendor network to claim or create a Crozdesk listing.", + "pricing": "free", + "pricing_note": "The vendor account application is free with no credit card required; paid advertising and PPC placements are offered through the Revleads partner network.", + "link_type": "nofollow", + "domain_rating": 74, + "monthly_traffic": 59600, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "crunchbase", + "name": "Crunchbase", + "type": "citation", + "website_url": "https://www.crunchbase.com", + "submission_url": "https://www.crunchbase.com/add-new", + "logo_url": "https://www.google.com/s2/favicons?domain=crunchbase.com&sz=64", + "description": "Crunchbase is a crowdsourced platform providing business information, funding data, and profiles on private and public companies.", + "audience": "Companies, founders, and investors in the technology ecosystem that need an accurate public company profile.", + "eligibility": "A registered, socially authenticated user can create or edit relevant company profiles; incomplete or off-topic profiles can be removed.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 91, + "monthly_traffic": 1600000, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "crunchbase-news", + "name": "Crunchbase News", + "type": "citation", + "website_url": "https://news.crunchbase.com", + "submission_url": "https://about.crunchbase.com/press", + "logo_url": "https://www.google.com/s2/favicons?domain=news.crunchbase.com&sz=64", + "description": "Crunchbase News publishes data-driven reporting on startups, founders, investors, and private markets.", + "audience": "Companies with significant funding, deals, or data-relevant private-market stories.", + "eligibility": "Coverage requires a newsworthy funding, deal, or market story and is selected editorially; a complete Crunchbase profile improves discoverability.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 91, + "monthly_traffic": 40200, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "css-design-awards", + "name": "CSS Design Awards", + "type": "directory", + "website_url": "https://www.cssdesignawards.com/", + "submission_url": "https://www.cssdesignawards.com/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=cssdesignawards.com&sz=64", + "description": "CSS Design Awards is an international web design awards platform that recognizes outstanding websites through daily, monthly, and yearly judged awards.", + "audience": "Solo designers, studios, agencies, and product teams with a live, original, polished website that they want considered for design awards.", + "eligibility": "Submissions must be live, complete, original websites and are judged on UI, UX, and innovation within the appropriate solo, studio, or agency tier.", + "pricing": "paid", + "pricing_note": "Each submission costs a flat US$50 fee regardless of the solo, studio, or agency tier.", + "link_type": "dofollow", + "domain_rating": 80, + "monthly_traffic": 12700, + "categories": [ + "design" + ] + }, + { + "slug": "cuspera", + "name": "Cuspera", + "type": "directory", + "website_url": "https://www.cuspera.com", + "submission_url": "https://www.cuspera.com/vendors", + "logo_url": "https://www.google.com/s2/favicons?domain=cuspera.com&sz=64", + "description": "AI-assisted business software discovery directory where vendors can create profile pages and be recommended to buyers by use case.", + "audience": "Business software vendors and teams researching software for specific business needs.", + "eligibility": "Business software vendors seeking a free directory listing and buyer discovery by use case.", + "pricing": "free", + "pricing_note": "Cuspera states that listing and traffic are always free; it separately offers a personalized demo for additional vendor services.", + "link_type": "nofollow", + "domain_rating": 49, + "monthly_traffic": 4800, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "cybo", + "name": "Cybo", + "type": "directory", + "website_url": "https://cybo.com/", + "submission_url": "https://support.cybo.com/hc/en-us/articles/8725672371469-How-do-I-Add-a-Business", + "logo_url": "https://www.google.com/s2/favicons?domain=cybo.com&sz=64", + "description": "Cybo is a global business directory with listings across hundreds of countries and languages.", + "audience": "Businesses that can publish accurate name, phone, contact, address, and category details for a public profile.", + "eligibility": "Businesses can create a free account to add a profile or claim an existing listing using their basic business details.", + "pricing": "free", + "pricing_note": "Account creation, business listings, and profile claims are free.", + "link_type": "nofollow", + "domain_rating": 75, + "monthly_traffic": 518700, + "categories": [ + "business" + ] + }, + { + "slug": "dang-ai", + "name": "Dang.ai", + "type": "directory", + "website_url": "https://dang.ai/", + "submission_url": "https://dang.ai/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=dang.ai&sz=64", + "description": "Dang.ai is a manually reviewed directory of AI tools and services.", + "audience": "AI tool and service creators whose products are a strong fit for a curated AI directory.", + "eligibility": "Submitted AI tools and services are manually reviewed and must be a good fit for Dang.ai's directory.", + "pricing": "paid", + "pricing_note": "The free option commonly requires a verification badge; paid expedited or verified listing options avoid that requirement.", + "link_type": "dofollow", + "domain_rating": 82, + "monthly_traffic": 33800, + "categories": [ + "ai" + ] + }, + { + "slug": "dark-design", + "name": "Dark", + "type": "directory", + "website_url": "https://www.dark.design/", + "submission_url": "https://www.dark.design/auth/login", + "logo_url": "https://www.dark.design/favicon.avif", + "description": "Curated dark-website design gallery organized across agency, AI, culture, personal, software, web3, and other visual categories.", + "audience": "Designers, developers, founders, and studios seeking dark-site inspiration or visibility for a strong dark-themed website.", + "eligibility": "A submitted site must be live, visually strong, substantially dark-themed, and suitable for one of the gallery categories. An account is required and publication remains curated.", + "pricing": "free", + "pricing_note": "The account-gated site-submission action does not state a listing fee; sponsorship is offered separately.", + "link_type": "unknown", + "domain_rating": 42, + "monthly_traffic": 1700, + "categories": [ + "design" + ] + }, + { + "slug": "dark-mode-design", + "name": "Dark Mode Design", + "type": "directory", + "website_url": "https://www.darkmodedesign.com/", + "submission_url": "https://www.darkmodedesign.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=darkmodedesign.com&sz=64", + "description": "Curated gallery of dark-mode websites and interface inspiration spanning product, portfolio, agency, ecommerce, editorial, and application design.", + "audience": "Designers, developers, agencies, and product teams seeking dark-interface inspiration and exposure for polished dark-mode websites.", + "eligibility": "Submit a live, visually polished website whose design substantially uses a dark color scheme. Editorial selection is discretionary and ordinary promotional pages without strong design work may be declined.", + "pricing": "free", + "pricing_note": "The official site-submission email action does not state a listing fee.", + "link_type": "unknown", + "domain_rating": 40, + "monthly_traffic": 1300, + "categories": [ + "design" + ] + }, + { + "slug": "dealify", + "name": "Dealify", + "type": "marketplace", + "website_url": "https://dealify.com/", + "submission_url": "https://dealify.com/pages/run-a-deal", + "logo_url": "https://dealify.com/cdn/shop/files/favicon_96x96.png", + "description": "Curated software-deal marketplace promoting lifetime, annual, credit-based, and free promotional offers to marketers, growth teams, founders, and software buyers.", + "audience": "SaaS and software companies prepared to run a discounted campaign and support a new customer cohort through a deal marketplace.", + "eligibility": "Applicants need a functioning software product, a compelling lifetime, annual, credit-based, or free offer, capacity to support customers, and willingness to agree campaign and commission terms with Dealify.", + "pricing": "paid", + "pricing_note": "There is no upfront application price published. Dealify describes a commission-based arrangement with flexible deal terms; the exact commercial split is agreed during review.", + "link_type": "unknown", + "domain_rating": 58, + "monthly_traffic": 2900, + "categories": [ + "business", + "marketing", + "saas" + ] + }, + { + "slug": "designspiration", + "name": "Designspiration", + "type": "community", + "website_url": "https://www.designspiration.com/", + "submission_url": "https://www.designspiration.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=designspiration.com&sz=64", + "description": "Visual discovery community for collecting and publishing design inspiration, colors, links, notes, screenshots, and uploaded creative work.", + "audience": "Designers, illustrators, photographers, studios, and creative teams seeking inspiration or discovery for strong visual work and source links.", + "eligibility": "Create an account and save a relevant source URL or upload visual media suitable for a curated design-inspiration community. Use work you have permission to share and provide an accurate source link and descriptive title.", + "pricing": "free", + "pricing_note": "Account signup and ordinary saving are free. Designspiration also advertises an optional Pro subscription; it is not required for a basic community submission.", + "link_type": "unknown", + "domain_rating": 78, + "monthly_traffic": 35000, + "categories": [ + "community", + "design" + ] + }, + { + "slug": "dev-community", + "name": "DEV Community", + "type": "citation", + "website_url": "https://dev.to", + "submission_url": "https://dev.to/settings/organization/new", + "logo_url": "https://www.google.com/s2/favicons?domain=dev.to&sz=64", + "description": "DEV Community is a platform where software developers and organizations publish articles, discuss topics, and share technical knowledge.", + "audience": "Software companies and open-source projects that can publish useful, non-spammy technical writing.", + "eligibility": "Anyone can create an organization after making a personal account, but posts must follow DEV's community rules and cannot be pure promotion.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 91, + "monthly_traffic": 999000, + "categories": [ + "community", + "developer-tools" + ] + }, + { + "slug": "devhunt", + "name": "DevHunt", + "type": "product-launch-site", + "website_url": "https://devhunt.org/", + "submission_url": "https://devhunt.org/", + "logo_url": "https://www.google.com/s2/favicons?domain=devhunt.org&sz=64", + "description": "DevHunt is an open-source, community-driven Product Hunt-style launchpad focused exclusively on developer tools.", + "audience": "Developers and teams launching open-source projects, APIs, SDKs, frameworks, libraries, IDEs, testing tools, and other products for developers.", + "eligibility": "Eligible launches are developer tools, including open-source projects, APIs and SDKs, frameworks and libraries, IDEs, testing tools, and monitoring or performance tools; GitHub authentication supports genuine voting and comments.", + "pricing": "free", + "pricing_note": "Basic launches are free with limited weekly slots; paid or sponsor options are also available, with direct contact required for current details.", + "link_type": "dofollow", + "domain_rating": 63, + "monthly_traffic": 14100, + "categories": [ + "developer-tools", + "startup" + ] + }, + { + "slug": "devpost", + "name": "Devpost", + "type": "community", + "website_url": "https://devpost.com", + "submission_url": "https://devpost.com/software/new?flow%5Bname%5D=add_software&ref_feature=built-with&ref_medium=button", + "logo_url": "https://www.google.com/s2/favicons?domain=devpost.com&sz=64", + "description": "Hackathon and software-project community where makers publish project profiles and competition submissions.", + "audience": "Developers, student builders, hackathon participants, and teams sharing software projects.", + "eligibility": "Software projects and hackathon entries; not a general paid SaaS-vendor directory.", + "pricing": "free", + "pricing_note": "The official Add your project route requires a Devpost account; no self-service listing fee is shown.", + "link_type": "nofollow", + "domain_rating": 88, + "monthly_traffic": 113400, + "categories": [ + "developer-tools" + ] + }, + { + "slug": "dodo-payments-index", + "name": "Index by Dodo Payments", + "type": "directory", + "website_url": "https://index.dodopayments.com/", + "submission_url": "https://index.dodopayments.com/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=index.dodopayments.com&sz=64", + "description": "Index by Dodo Payments is a directory of startups, indie-hacker projects, and SaaS tools for discovery, comparison, and visibility.", + "audience": "Startup founders, indie hackers, and software teams with a useful SaaS or AI tool.", + "eligibility": "Listings must be useful, high-quality startups, indie-hacker projects, SaaS products, or AI tools and are reviewed by the Dodo Payments team.", + "pricing": "free", + "pricing_note": "Listing submission is free and is subject to Dodo Payments team verification.", + "link_type": "dofollow", + "domain_rating": 79, + "monthly_traffic": 3600, + "categories": [ + "indie", + "startup" + ] + }, + { + "slug": "dokeyai", + "name": "DokeyAI", + "type": "citation", + "website_url": "https://dokeyai.com/", + "submission_url": "https://dokeyai.com/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=dokeyai.com&sz=64", + "description": "DokeyAI is an AI-tools directory and product showcase with thousands of AI websites and tools across dozens of categories.", + "audience": "Makers of AI tools seeking a directory mention through DokeyAI's submission options.", + "eligibility": "The submission must be an AI tool; the free option requires a specified partner link in the product website footer.", + "pricing": "paid", + "pricing_note": "The free route requires a partner footer backlink; paid Submit Packs cost about $29.90–$69 and offer a no-backlink option.", + "link_type": "dofollow", + "domain_rating": 56, + "monthly_traffic": 1200, + "categories": [ + "ai", + "saas" + ] + }, + { + "slug": "dribbble", + "name": "Dribbble", + "type": "community", + "website_url": "https://dribbble.com", + "submission_url": "https://dribbble.com", + "logo_url": "https://www.google.com/s2/favicons?domain=dribbble.com&sz=64", + "description": "Design community and marketplace where designers and teams share work as \"Shots\", gain visibility, offer services, and connect with clients for projects.", + "audience": "Designers and teams sharing work for visibility, feedback, and client opportunities.", + "eligibility": "Anyone can create a free account; publishing publicly visible Shots and being discoverable requires applying for Designer status, reviewed by Dribbble's curation team (typically within about 24 hours).", + "pricing": "free", + "pricing_note": "Free to sign up, apply for Designer status, and publish basic Shots; Pro plans add ranking boosts, more uploads, and team seats.", + "link_type": "nofollow", + "domain_rating": 93, + "monthly_traffic": 2600000, + "categories": [ + "design" + ] + }, + { + "slug": "droidappshowcase", + "name": "r/droidappshowcase", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/droidappshowcase/", + "submission_url": "https://www.reddit.com/r/droidappshowcase/submit", + "logo_url": "https://styles.redditmedia.com/t5_g9r5du/styles/communityIcon_tb963wbo4vag1.jpg?width=256&s=0f5503b99ac38f0a8f8340eaf6f54d338885e53f", + "description": "The premier showcase for Android developers to share apps and users to find exclusive deals and promo codes.", + "audience": "Android app developers and users discovering mobile apps, games, and promotions.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 14013, + "growth_30d_absolute": 1950, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/droidappshowcase/about/rules" + } + }, + { + "slug": "e27", + "name": "e27", + "type": "directory", + "website_url": "https://e27.co/", + "submission_url": "https://e27.co/startup/create/profile/", + "logo_url": "https://www.google.com/s2/favicons?domain=e27.co&sz=64", + "description": "e27 connects the Asian startup ecosystem through startup and investor profiles, news, events, contributor content, and networking tools.", + "audience": "Startups, with particular relevance to Southeast Asian and wider Asian technology ecosystems, that can create a complete company profile.", + "eligibility": "Startups can create or claim a profile after making an account; funding submissions are reviewed and fact-checked, while contributor content must add substantive value beyond pure promotion.", + "pricing": "free", + "pricing_note": "Basic company profiles, funding submissions, and contributor content are free; Pro membership adds visibility and networking features.", + "link_type": "dofollow", + "domain_rating": 76, + "monthly_traffic": 22300, + "categories": [ + "community", + "startup" + ] + }, + { + "slug": "easy-with-ai", + "name": "Easy With AI", + "type": "directory", + "website_url": "https://easywithai.com/", + "submission_url": "https://easywithai.com/submit-tool/", + "logo_url": "https://www.google.com/s2/favicons?domain=easywithai.com&sz=64", + "description": "Easy With AI is a curated AI tools directory with more than 50 categories, focused on making AI tools and services discoverable.", + "audience": "Teams with AI tools that are not already listed and want discovery in a curated AI-tools directory.", + "eligibility": "The submission must be an AI tool not already listed; NSFW tools are permitted only under strict no-explicit-content rules and receive limited visibility.", + "pricing": "paid", + "pricing_note": "A standard permanent listing costs $125 one time; qualifying free, open-source educational, or academic-research AI tools may receive a free or discounted listing, and featured placements cost extra.", + "link_type": "dofollow", + "domain_rating": 59, + "monthly_traffic": 26900, + "categories": [ + "ai", + "saas" + ] + }, + { + "slug": "ebool", + "name": "eBool", + "type": "directory", + "website_url": "https://www.ebool.com/", + "submission_url": "https://www.ebool.com/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=ebool.com&sz=64", + "description": "eBool is a product directory and index of software, business tools, and services across thousands of categories.", + "audience": "Makers of software, business, and web products seeking directory discovery.", + "eligibility": "Software, business, and web products are reviewed for acceptance; descriptions must be non-promotional, and paid submissions are refunded if rejected.", + "pricing": "free", + "pricing_note": "The free queue can take about six months; one-time Premium, Pro, and Super options cost about $47, $117, and $197 for priority review and stronger ranking.", + "link_type": "dofollow", + "domain_rating": 51, + "monthly_traffic": 1700, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "ecomm-design", + "name": "Ecomm Design", + "type": "directory", + "website_url": "https://ecomm.design/", + "submission_url": "https://ecomm.design/submit/", + "logo_url": "https://www.google.com/s2/favicons?domain=ecomm.design&sz=64", + "description": "Ecomm Design is a curated gallery of ecommerce website designs organized by vertical, platform, UX practice, and technology stack.", + "audience": "Owners of online shops and ecommerce stores seeking inclusion in a curated design gallery.", + "eligibility": "Only online shops and ecommerce stores are accepted; portfolio and studio websites are explicitly excluded.", + "pricing": "free", + "pricing_note": "The submission and about pages do not list a paid listing option.", + "link_type": "dofollow", + "domain_rating": 71, + "monthly_traffic": 50700, + "categories": [ + "design", + "ecommerce" + ] + }, + { + "slug": "ecommercefuel", + "name": "eCommerceFuel", + "type": "citation", + "website_url": "https://www.ecommercefuel.com/", + "submission_url": "https://www.ecommercefuel.com/saas-software-partnerships/", + "logo_url": "https://www.google.com/s2/favicons?domain=ecommercefuel.com&sz=64", + "description": "eCommerceFuel is a vetted community for seven- and eight-figure ecommerce owners, with a member-written SaaS and agency review directory and a separate vendor partner program.", + "audience": "Software vendors serving established ecommerce businesses that want consideration for eCommerceFuel's SaaS partnership program.", + "eligibility": "The SaaS partnership program is for software vendors whose products the team believes in; service providers, brokers, coaches, and consultants should not apply.", + "pricing": "free", + "pricing_note": "The public SaaS partnership page says the program is at no cost to vendors; event sponsorship pricing is not published.", + "link_type": "dofollow", + "domain_rating": 72, + "monthly_traffic": 1800, + "categories": [ + "business", + "ecommerce", + "saas" + ] + }, + { + "slug": "elocal", + "name": "eLocal", + "type": "marketplace", + "website_url": "https://www.elocal.com/", + "submission_url": "https://www.elocal.com/signup/step1/", + "logo_url": "https://www.google.com/s2/favicons?domain=elocal.com&sz=64", + "description": "Pay-per-call marketplace connecting consumers with local professionals across home services, legal, insurance, medical, and other service categories.", + "audience": "Established local service businesses and professional practices seeking qualified inbound calls from consumers in the United States.", + "eligibility": "Applicants must provide an eligible service category and operating area and be able to serve local customers by phone. Acceptance, coverage, compliance requirements, and lead availability vary by category and location.", + "pricing": "paid", + "pricing_note": "eLocal is a commercial pay-per-call lead network. The public signup does not publish a fixed listing price; campaign pricing and lead terms are established during onboarding and governed by the advertiser terms.", + "link_type": "unknown", + "domain_rating": 74, + "monthly_traffic": 9300, + "categories": [ + "business" + ] + }, + { + "slug": "engadget", + "name": "Engadget", + "type": "citation", + "website_url": "https://www.engadget.com", + "submission_url": "https://www.engadget.com/about-faq-194611880.html", + "logo_url": "https://www.google.com/s2/favicons?domain=engadget.com&sz=64", + "description": "Engadget is a technology news and reviews publication covering gadgets, consumer technology, and digital culture.", + "audience": "Technology companies with a genuinely relevant product or story that Engadget's editorial team independently considers for coverage.", + "eligibility": "Engadget's reviews team independently selects products based on editorial interest and fit; pitches are welcome but do not guarantee coverage.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 90, + "monthly_traffic": 276500, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "entrepreneur", + "name": "Entrepreneur", + "type": "citation", + "website_url": "https://www.entrepreneur.com", + "submission_url": "https://www.entrepreneur.com/press-release/about", + "logo_url": "https://www.google.com/s2/favicons?domain=entrepreneur.com&sz=64", + "description": "Entrepreneur is a business media publication that publishes staff and contributor content, paid press releases, and business-opportunity listings.", + "audience": "Businesses with an objective, newsworthy entrepreneurship-related announcement or industry experts who meet the contributor program's review standards.", + "eligibility": "Entrepreneur Wire accepts business-related announcements in an objective 400–1,200-word format; contributor and franchise routes have separate selection and qualification requirements.", + "pricing": "paid", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 91, + "monthly_traffic": 637300, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "eu-startups", + "name": "EU-Startups", + "type": "citation", + "website_url": "https://www.eu-startups.com/", + "submission_url": "https://www.eu-startups.com/about", + "logo_url": "https://www.google.com/s2/favicons?domain=eu-startups.com&sz=64", + "description": "EU-Startups is a publication covering European startups through news, analysis, interviews, and ecosystem reporting.", + "audience": "European technology and internet startups with a timely, newsworthy story relevant to the European startup ecosystem.", + "eligibility": "Editorial coverage is selective and generally requires a European startup story with sufficient relevance and news value; summit applications have separate temporary criteria when open.", + "pricing": "free", + "pricing_note": "Editorial pitches are free when accepted; sponsored posts, advertising, newsletters, and event participation are paid separately.", + "link_type": "dofollow", + "domain_rating": 78, + "monthly_traffic": 11600, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "european-alternatives", + "name": "European Alternatives", + "type": "directory", + "website_url": "https://european-alternatives.eu", + "submission_url": "https://european-alternatives.eu/register", + "logo_url": "https://www.google.com/s2/favicons?domain=european-alternatives.eu&sz=64", + "description": "European Alternatives is a directory of European digital services and products, including cloud services and SaaS products.", + "audience": "European software companies and self-hostable open-source projects seeking discovery by people looking for European alternatives.", + "eligibility": "The company and any parent or holding company must be based in an EU, EEA, EFTA, or DCFTA member country, or in the UK. Self-hostable open-source projects are also eligible. Hosting providers must operate on infrastructure that meets the directory's European ownership criteria.", + "pricing": "unknown", + "pricing_note": "The site does not publish a fee for product suggestions. Its sponsorship and affiliate programmes are separate from the listing-suggestion route.", + "link_type": "unknown", + "domain_rating": 73, + "monthly_traffic": 43800, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "ezilon", + "name": "Ezilon", + "type": "directory", + "website_url": "https://www.ezilon.com/", + "submission_url": "https://www.ezilon.com/general_url_submission.htm", + "logo_url": "https://www.google.com/s2/favicons?domain=ezilon.com&sz=64", + "description": "Ezilon is a human-reviewed regional web directory with Europe-wide business, software, internet-service, education, and government categories.", + "audience": "European businesses and software providers seeking a reviewed regional directory listing in the category that matches their offering.", + "eligibility": "Ezilon accepts active quality websites with English content that fit a selected regional category. It rejects sites under construction, redirects, duplicate or similar submissions, and prohibited content.", + "pricing": "paid", + "pricing_note": "Non-commercial government, NGO, association, charity, research, and education sites are free. Commercial listings require payment, but Ezilon does not publish the amount on its submission information page and says a declined paid listing is refunded.", + "link_type": "unknown", + "domain_rating": 54, + "monthly_traffic": 32300, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "f6s", + "name": "F6S", + "type": "directory", + "website_url": "https://www.f6s.com/", + "submission_url": "https://www.f6s.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=f6s.com&sz=64", + "description": "F6S is a global startup platform where founders create company profiles and access funding programs, accelerators, software perks, and jobs.", + "audience": "Technology startup founders who can create a detailed public company profile.", + "eligibility": "Technology startups and founders can create a company profile with product, team, and company-stage details.", + "pricing": "free", + "pricing_note": "A basic company profile is free; Grok notes that some premium features may be paid.", + "link_type": "dofollow", + "domain_rating": 83, + "monthly_traffic": 274200, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "fark", + "name": "Fark", + "type": "community", + "website_url": "https://www.fark.com/", + "submission_url": "https://www.fark.com/submit/", + "logo_url": "https://www.google.com/s2/favicons?domain=fark.com&sz=64", + "description": "Community-curated news and entertainment aggregator where registered members submit noteworthy links for editorial selection and discussion.", + "audience": "News and internet-culture readers, link submitters, and community members discussing unusual, entertaining, and timely stories.", + "eligibility": "A submission requires a Fark account and a publicly accessible link with a concise headline; acceptance is editorial and promotional spam is not guaranteed placement.", + "pricing": "free", + "pricing_note": "The standard link-submission route is available to registered users without a listed submission fee; optional subscriptions and advertising are separate.", + "link_type": "nofollow", + "domain_rating": 73, + "monthly_traffic": 17600, + "categories": [ + "community", + "social" + ] + }, + { + "slug": "fast-company-most-innovative-companies", + "name": "Fast Company Most Innovative Companies", + "type": "citation", + "website_url": "https://www.fastcompany.com", + "submission_url": "https://www.fastcompany.com/apply/most-innovative-companies", + "logo_url": "https://www.google.com/s2/favicons?domain=fastcompany.com&sz=64", + "description": "Fast Company is a business media brand covering innovation, leadership, technology, design, and progressive ideas in companies and culture.", + "audience": "Organizations that can demonstrate recent innovation with measurable business, industry, or cultural impact.", + "eligibility": "Most Innovative Companies applicants need specific innovations from the previous year and evidence of their impact; Fast Company's editorial team selects the final list and payment does not guarantee inclusion.", + "pricing": "paid", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 91, + "monthly_traffic": 275800, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "featured-customers", + "name": "FeaturedCustomers", + "type": "directory", + "website_url": "https://www.featuredcustomers.com/", + "submission_url": "https://featuredcustomers.com/vendor-solutions", + "logo_url": "https://www.google.com/s2/favicons?domain=featuredcustomers.com&sz=64", + "description": "FeaturedCustomers is a customer-reference platform for B2B software and services that aggregates authenticated testimonials, case studies, success stories, and videos for buyer research.", + "audience": "B2B software and service vendors that can provide and manage customer references such as case studies, testimonials, and videos.", + "eligibility": "Eligible vendors are B2B software and service companies able to provide and manage customer-reference content.", + "pricing": "free", + "pricing_note": "A Free Verified Vendor profile is available; Premium Vendor is paid, with no exact public price shown on the reviewed pages.", + "link_type": "nofollow", + "domain_rating": 72, + "monthly_traffic": 15400, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "feedough", + "name": "Feedough", + "type": "citation", + "website_url": "https://www.feedough.com/", + "submission_url": "https://www.feedough.com/submit-your-startup/", + "logo_url": "https://www.google.com/s2/favicons?domain=feedough.com&sz=64", + "description": "Feedough is an entrepreneurship and AI resource site that publishes custom-written startup review articles with email, social, and notification distribution.", + "audience": "Founders of disruptive startups seeking editorial startup coverage.", + "eligibility": "Startups must be disruptive: changing how a market operates, creating a market, solving a previously unaddressed problem, or providing a novel solution; agencies, blogs, websites, and non-disruptive businesses are excluded.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 71, + "monthly_traffic": 33800, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "find-my-ai-tool", + "name": "Find My AI tool", + "type": "directory", + "website_url": "https://findmyaitool.com/", + "submission_url": "https://findmyaitool.com/submit-tool", + "logo_url": "https://www.google.com/s2/favicons?domain=findmyaitool.com&sz=64", + "description": "Find My AI tool is a directory of more than 2,500 AI tools, GPTs, and agents organized by category, industry, and pricing model.", + "audience": "Teams promoting AI tools or AI agents that need a curated directory listing.", + "eligibility": "An AI tool or agent must pass the directory team's listing criteria; rejected submissions receive a full refund.", + "pricing": "paid", + "pricing_note": "Listings use one-time paid plans beginning at about $80, with higher tiers for featured, homepage, or category placement.", + "link_type": "dofollow", + "domain_rating": 37, + "monthly_traffic": 11500, + "categories": [ + "ai", + "saas" + ] + }, + { + "slug": "findstack", + "name": "Findstack", + "type": "directory", + "website_url": "https://findstack.com/", + "submission_url": "https://findstack.com/get-listed", + "logo_url": "https://www.google.com/s2/favicons?domain=findstack.com&sz=64", + "description": "Findstack is a business-software comparison platform with product features, pricing, integrations, and aggregated user reviews across hundreds of categories.", + "audience": "B2B and software product teams seeking a buyer-facing product profile.", + "eligibility": "Vendors submit product details; basic inclusion is available regardless of whether the vendor purchases premium enhancements.", + "pricing": "free", + "pricing_note": "Basic inclusion is free; optional paid premium profile enhancements do not affect inclusion or ranking.", + "link_type": "nofollow", + "domain_rating": 77, + "monthly_traffic": 3600, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "firefox-add-ons", + "name": "Firefox Add-ons", + "type": "marketplace", + "website_url": "https://addons.mozilla.org/", + "submission_url": "https://extensionworkshop.com/documentation/publish/submitting-an-add-on/", + "logo_url": "https://www.google.com/s2/favicons?domain=addons.mozilla.org&sz=64", + "description": "Mozilla's official catalog for Firefox browser extensions and themes, where users discover, review, and install add-ons.", + "audience": "Browser-extension developers distributing a reviewed Firefox add-on to the public.", + "eligibility": "The add-on must use a supported package, comply with Mozilla add-on policies, provide accurate metadata, and pass automated validation and any required human review.", + "pricing": "free", + "pricing_note": "Mozilla provides the AMO submission and signing workflow without stating a developer registration or listing fee.", + "link_type": "nofollow", + "domain_rating": 96, + "monthly_traffic": 1500000, + "categories": [ + "developer-tools", + "productivity" + ] + }, + { + "slug": "flutterdev", + "name": "r/FlutterDev", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/FlutterDev/", + "submission_url": "https://www.reddit.com/r/FlutterDev/submit", + "logo_url": "https://b.thumbs.redditmedia.com/X2q6YuAJK0WWktTZcp-1D-4HnxOQ8oH1DqJdrLQtS8s.png", + "description": "A community for the publishing of news and discussion about Flutter.", + "audience": "Flutter developers sharing source code or specific implementation insights.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 182222, + "growth_30d_absolute": 1465, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/FlutterDev/about/rules" + } + }, + { + "slug": "forbes-cloud-100", + "name": "Forbes Cloud 100", + "type": "citation", + "website_url": "https://www.forbes.com/cloud100", + "submission_url": "https://thecloud100.com", + "logo_url": "https://www.google.com/s2/favicons?domain=forbes.com&sz=64", + "description": "The Forbes Cloud 100, produced with Bessemer Venture Partners and Salesforce Ventures, is an annual editorial ranking of the top 100 private cloud/SaaS companies.", + "audience": "Later-stage, well-funded private cloud/SaaS companies with substantial revenue and funding — not early-stage or bootstrapped startups.", + "eligibility": "Editorial nomination and judging by Forbes/Bessemer/Salesforce Ventures based on market size, revenue, growth rate, valuation, and company culture; there is no self-serve submission form and no way to buy or guarantee placement.", + "pricing": "free", + "pricing_note": "Free to nominate; there is no fee, but placement is competitive and judged, not guaranteed.", + "link_type": "nofollow", + "domain_rating": 94, + "monthly_traffic": 18100000, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "freebie-supply", + "name": "Freebie Supply", + "type": "citation", + "website_url": "https://freebiesupply.com/", + "submission_url": "https://freebiesupply.com/submit-freebie/", + "logo_url": "https://www.google.com/s2/favicons?domain=freebiesupply.com&sz=64", + "description": "Freebie Supply is a design-resource collection and catalog of free PSD, Sketch, Figma, Illustrator, XD, and related resources shared by designers.", + "audience": "Designers and product teams that have created genuinely free design resources to share with the design community.", + "eligibility": "Contributions must be free design resources created by the submitter and suitable for sharing with the community.", + "pricing": "free", + "pricing_note": "No contribution fee is stated for sharing a free design resource.", + "link_type": "dofollow", + "domain_rating": 73, + "monthly_traffic": 23500, + "categories": [ + "community", + "design" + ] + }, + { + "slug": "freecodecamp-forum", + "name": "freeCodeCamp Forum", + "type": "community", + "website_url": "https://forum.freecodecamp.org/", + "submission_url": "https://forum.freecodecamp.org/c/project-feedback/409", + "logo_url": "https://www.google.com/s2/favicons?domain=forum.freecodecamp.org&sz=64", + "description": "freeCodeCamp's public developer forum for programming help, technical discussion, open-source contribution, career support, and constructive project showcases.", + "audience": "Developers, programming learners, open-source contributors, and technical builders seeking help, feedback, and practical discussion.", + "eligibility": "Relevant projects may be showcased in the I Built This category after the account reaches forum trust Level 1; posts must seek constructive feedback and follow community anti-spam rules.", + "pricing": "free", + "pricing_note": "Forum membership and posting are free.", + "link_type": "nofollow", + "domain_rating": 89, + "monthly_traffic": 66000, + "categories": [ + "community", + "developer-tools" + ] + }, + { + "slug": "future-tools", + "name": "Future Tools", + "type": "directory", + "website_url": "https://futuretools.io/", + "submission_url": "https://futuretools.io/submit-a-tool", + "logo_url": "https://www.google.com/s2/favicons?domain=futuretools.io&sz=64", + "description": "AI tool discovery directory and editorial site where users browse categorized tools, rankings, newly added products, and daily AI news.", + "audience": "AI enthusiasts, creators, professionals, and teams looking for AI products, comparisons, and industry updates.", + "eligibility": "The submission form accepts AI tools with a public tool name, URL, short description, category, and pricing model; submissions are reviewed by the Future Tools team.", + "pricing": "free", + "pricing_note": "The public submission form does not list a submission fee.", + "link_type": "nofollow", + "domain_rating": 69, + "monthly_traffic": 62100, + "categories": [ + "ai", + "marketing" + ] + }, + { + "slug": "futurepedia", + "name": "Futurepedia", + "type": "directory", + "website_url": "https://www.futurepedia.io", + "submission_url": "https://www.futurepedia.io/submit-tool", + "logo_url": "https://www.futurepedia.io/favicon.ico", + "description": "One of the largest AI tool directories, showcasing the best AI tools, GPTs, and apps for professionals, updated daily.", + "audience": "Professionals discovering AI tools, GPTs, and apps for work", + "eligibility": "AI tools, GPTs, and apps; all submissions are subject to editorial approval and may be denied or removed.", + "pricing": "paid", + "pricing_note": "Basic listing $247 one-time (currently sold out); Verified listing $497 one-time, published within 2 business days. Enterprise packages have custom pricing.", + "link_type": "nofollow", + "domain_rating": 72, + "monthly_traffic": 65700, + "categories": [ + "ai", + "saas" + ] + }, + { + "slug": "g2", + "name": "G2", + "type": "directory", + "website_url": "https://g2.com", + "submission_url": "https://g2.com", + "logo_url": "https://www.google.com/s2/favicons?domain=g2.com&sz=64", + "description": "G2 is a platform where buyers research business software and services using verified user reviews and product profiles.", + "audience": "Business software buyers researching reviews and comparisons, and software vendors managing product profiles.", + "eligibility": "Products must not be B2C or in alpha/beta; G2 verifies eligibility and categorization.", + "pricing": "free", + "pricing_note": "Basic profile submission and claiming are free.", + "link_type": "nofollow", + "domain_rating": 91, + "monthly_traffic": 679600, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "gamedevmarketing", + "name": "r/gameDevMarketing", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/gameDevMarketing/", + "submission_url": "https://www.reddit.com/r/gameDevMarketing/submit", + "logo_url": "https://styles.redditmedia.com/t5_cq5m9t/styles/communityIcon_z1z5ppguvmce1.png?width=256&s=4fb0262d4d5285138fa989b7fc9b1f01d28195d3", + "description": "This subreddit encompasses all aspects of game development marketing and serves as a sister community to r/gameDev. Its primary purpose is to provide a more focused discussion on one of the most prevalent topics within the game development community.", + "audience": "Indie game developers sharing marketing lessons, postmortems, analytics, and feedback requests.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "indie", + "marketing" + ], + "subreddit": { + "members_count": 9180, + "growth_30d_absolute": 1354, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/gameDevMarketing/about/rules" + } + }, + { + "slug": "gartner-peer-insights", + "name": "Gartner Peer Insights", + "type": "directory", + "website_url": "https://www.gartner.com", + "submission_url": "https://www.gartner.com/peer-insights/vendor-portal/overview", + "logo_url": "https://www.google.com/s2/favicons?domain=gartner.com&sz=64", + "description": "Gartner is a research and advisory firm that runs Peer Insights, a platform for verified enterprise software and service reviews.", + "audience": "Enterprise software and service vendors with generally available offerings that fit an existing Gartner Peer Insights market.", + "eligibility": "The offering must match an existing market's required capabilities, be generally available rather than beta-only, and have public evidence for Gartner's review.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 92, + "monthly_traffic": 1600000, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "geekwire", + "name": "GeekWire", + "type": "directory", + "website_url": "https://www.geekwire.com/startup-list/", + "submission_url": "https://www.geekwire.com/submit-startup/", + "logo_url": "https://www.google.com/s2/favicons?domain=geekwire.com&sz=64", + "description": "GeekWire maintains a startup database and ranking for eligible Pacific Northwest technology startups.", + "audience": "Privately held technology startups headquartered in Washington, Oregon, Idaho, or British Columbia and founded within the last 15 years.", + "eligibility": "Companies must meet GeekWire's regional, age, ownership, and technology-startup criteria and pass human review and address verification.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 86, + "monthly_traffic": 95700, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "getapp", + "name": "GetApp", + "type": "directory", + "website_url": "https://getapp.com", + "submission_url": "https://getapp.com", + "logo_url": "https://www.google.com/s2/favicons?domain=getapp.com&sz=64", + "description": "GetApp is a business software discovery platform with product profiles, reviews, and comparisons across software categories.", + "audience": "Business leaders and software buyers researching and comparing business applications.", + "eligibility": "Products must be publicly available packaged software that fits an existing category and has a trial, demo, or request-information call to action; excluded products include marketplaces, custom software, and personal apps.", + "pricing": "free", + "pricing_note": "Basic listing is free; paid options offer enhanced visibility.", + "link_type": "nofollow", + "domain_rating": 85, + "monthly_traffic": 60100, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "github-topics", + "name": "GitHub Topics", + "type": "community", + "website_url": "https://github.com/topics", + "submission_url": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/classifying-your-repository-with-topics", + "logo_url": "https://www.google.com/s2/favicons?domain=github.com&sz=64", + "description": "GitHub's public topic system groups repositories by technology, subject, industry, and community-defined interests to support project discovery.", + "audience": "Open-source maintainers and developers seeking relevant discovery for public repositories among GitHub users browsing topic pages.", + "eligibility": "You must have sufficient repository permissions and add only concise, relevant topics that accurately classify the repository. Topics use lowercase letters, numbers, and hyphens, with limits documented by GitHub.", + "pricing": "free", + "pricing_note": "Adding topics to a repository is a standard GitHub repository feature and has no separate submission fee.", + "link_type": "unknown", + "domain_rating": 97, + "monthly_traffic": 45500000, + "categories": [ + "community", + "developer-tools" + ] + }, + { + "slug": "gizmodo", + "name": "Gizmodo", + "type": "citation", + "website_url": "https://gizmodo.com", + "submission_url": "https://gizmodo.com/how-to-tip-gizmodo-1843880833", + "logo_url": "https://www.google.com/s2/favicons?domain=gizmodo.com&sz=64", + "description": "Gizmodo is a technology and culture news publication that accepts editorial tips and freelance story pitches.", + "audience": "Companies, experts, and writers with a well-supported news tip or original story that fits Gizmodo's technology, culture, science, or consumer-technology coverage.", + "eligibility": "Editorial pitches require a strong original angle and, for freelance work, relevant clips; Gizmodo discourages PR spam and independently selects coverage.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 90, + "monthly_traffic": 535400, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "glassdoor", + "name": "Glassdoor", + "type": "directory", + "website_url": "https://www.glassdoor.com", + "submission_url": "https://www.glassdoor.com/employers/sign-up/", + "logo_url": "https://www.google.com/s2/favicons?domain=glassdoor.com&sz=64", + "description": "Glassdoor is a platform for company reviews, salary data, interview insights, and job listings contributed by employees and managed by employers.", + "audience": "Employers that want to claim and manage their public company profile and recruiting presence.", + "eligibility": "Employer representatives must use a matching company-domain work email and provide company details for Glassdoor Trust & Safety verification.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 91, + "monthly_traffic": 3400000, + "categories": [ + "business" + ] + }, + { + "slug": "goodfirms", + "name": "GoodFirms", + "type": "directory", + "website_url": "https://www.goodfirms.co", + "submission_url": "https://www.goodfirms.co/get-listed", + "logo_url": "https://www.google.com/s2/favicons?domain=goodfirms.co&sz=64", + "description": "GoodFirms is a B2B directory for software vendors and service companies with verified profiles, reviews, and category rankings.", + "audience": "B2B service companies and software vendors that can pass GoodFirms' company and product research verification.", + "eligibility": "Listings are subject to GoodFirms research verification, including legitimate business details, portfolio or product evidence, and client-reference checks where applicable.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 86, + "monthly_traffic": 173800, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "google-business-profile", + "name": "Google Business Profile", + "type": "citation", + "website_url": "https://business.google.com", + "submission_url": "https://support.google.com/business/answer/2911778", + "logo_url": "https://www.google.com/s2/favicons?domain=business.google.com&sz=64", + "description": "Google Business Profile is Google's free tool that lets eligible local businesses manage how they appear in Google Search and Maps.", + "audience": "Storefront and service-area businesses with in-person customer contact — not purely online/digital software products.", + "eligibility": "Requires the business to make in-person contact with customers during its stated hours (storefront or service-area businesses that travel to customers). Online-only businesses, brands, and pure digital/SaaS operations without in-person contact are explicitly ineligible and cannot create a profile at all.", + "pricing": "free", + "pricing_note": "Free to create and manage a basic profile.", + "link_type": "nofollow", + "domain_rating": 99, + "monthly_traffic": 2200000, + "categories": [ + "business" + ] + }, + { + "slug": "google-workspace-marketplace", + "name": "Google Workspace Marketplace", + "type": "marketplace", + "website_url": "https://workspace.google.com/marketplace/", + "submission_url": "https://developers.google.com/workspace/marketplace/how-to-publish", + "logo_url": "https://www.google.com/s2/favicons?domain=workspace.google.com&sz=64", + "description": "Google's marketplace for add-ons, Chat apps, Drive apps, Classroom integrations, and web applications that extend Google Workspace.", + "audience": "Developers with a production application that meaningfully integrates with at least one Google Workspace service.", + "eligibility": "The app must extend a supported Workspace application, use a Google Cloud project, comply with Marketplace policies, and pass Google review for public distribution.", + "pricing": "free", + "pricing_note": "Google documents the public configuration and review workflow without a Marketplace listing fee. Separate infrastructure or third-party security-assessment costs can apply to the app.", + "link_type": "dofollow", + "domain_rating": 100, + "monthly_traffic": 12700000, + "categories": [ + "developer-tools", + "productivity" + ] + }, + { + "slug": "guardian-technology", + "name": "Guardian Technology", + "type": "citation", + "website_url": "https://www.theguardian.com", + "submission_url": "https://help.theguardian.com/article/how-do-i-contact-the-newsroom-or-pitch-a-story", + "logo_url": "https://www.google.com/s2/favicons?domain=theguardian.com&sz=64", + "description": "British newspaper and global digital news organization producing independent journalism.", + "audience": "Genuinely newsworthy companies with a demonstrable public-interest story, not a self-serve company-listing channel.", + "eligibility": "Unsolicited pitches need a strong, evidence-based public-interest angle; this is an editorial-nomination route rather than a self-serve company listing.", + "pricing": "free", + "pricing_note": "Free to pitch; editorial consideration is not paid placement and does not guarantee coverage.", + "link_type": "dofollow", + "domain_rating": 93, + "monthly_traffic": 29900000, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "gust", + "name": "Gust", + "type": "directory", + "website_url": "https://gust.com/", + "submission_url": "https://gust.helpscoutdocs.com/article/211-publishing-your-profile", + "logo_url": "https://www.google.com/s2/favicons?domain=gust.com&sz=64", + "description": "Gust is a global startup platform and network that helps companies incorporate, manage equity, and connect with investors, angel groups, and accelerators through searchable profiles and applications.", + "audience": "Startups and entrepreneurs that want to publish a company profile for public startup-directory visibility and investor or accelerator discovery.", + "eligibility": "Registered users can create and publish a company profile; full company details are shared only on request or during an application.", + "pricing": "free", + "pricing_note": "Basic profile creation, publishing, and directory visibility are free; Gust Launch and related incorporation or equity-management products are paid separately.", + "link_type": "dofollow", + "domain_rating": 75, + "monthly_traffic": 5900, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "hacker-news", + "name": "Hacker News", + "type": "citation", + "website_url": "https://news.ycombinator.com", + "submission_url": "https://news.ycombinator.com/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=news.ycombinator.com&sz=64", + "description": "Hacker News is Y Combinator's technology news community where users discuss startups and post Show HN launches for things they built.", + "audience": "Makers with a working software or hardware project they personally built and can discuss with the community.", + "eligibility": "Show HN posts must demonstrate something the submitter built that others can try; pure landing pages, waitlists, fundraisers, and minor updates are not eligible.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 91, + "monthly_traffic": 630600, + "categories": [ + "developer-tools", + "startup" + ] + }, + { + "slug": "hackernoon", + "name": "HackerNoon", + "type": "citation", + "website_url": "https://hackernoon.com", + "submission_url": "https://help.hackernoon.com/submit-a-story-checklist", + "logo_url": "https://www.google.com/s2/favicons?domain=hackernoon.com&sz=64", + "description": "HackerNoon is a technology publishing platform where individuals and companies publish technology stories and updates.", + "audience": "Technology companies and contributors with original, well-disclosed stories relevant to software, startups, AI, or other technology topics.", + "eligibility": "Stories must meet HackerNoon's editorial requirements for originality, quality, and disclosures; company publishing uses its separate business publishing route.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 88, + "monthly_traffic": 51500, + "categories": [ + "developer-tools", + "startup" + ] + }, + { + "slug": "hashnode", + "name": "Hashnode", + "type": "community", + "website_url": "https://hashnode.com/", + "submission_url": "https://hashnode.com/login", + "logo_url": "https://www.google.com/s2/favicons?domain=hashnode.com&sz=64", + "description": "Developer-focused blogging community where individuals and teams publish technical articles, follow writers, discuss posts, and distribute content through the Hashnode feed.", + "audience": "Software developers, engineering teams, technical founders, and readers looking for practical programming and technology content.", + "eligibility": "Anyone may create an account and publish, but posts should provide substantive technical or developer-focused value rather than exist only to promote a product or drop a link.", + "pricing": "free", + "pricing_note": "Account creation and basic publishing are free; optional Hashnode Pro features are not required to publish.", + "link_type": "dofollow", + "domain_rating": 83, + "monthly_traffic": 50500, + "categories": [ + "community", + "developer-tools" + ] + }, + { + "slug": "hotfrog", + "name": "Hotfrog", + "type": "citation", + "website_url": "https://www.hotfrog.com/", + "submission_url": "https://www.hotfrog.com/add", + "logo_url": "https://www.google.com/s2/favicons?domain=hotfrog.com&sz=64", + "description": "Hotfrog is a global local-business directory where businesses create profiles to promote their products and services.", + "audience": "Businesses with an operated physical location or service area that can create and verify a complete local business profile.", + "eligibility": "A business must provide its company name, location or map pin, and profile details, then verify ownership; a purely digital product without a local business presence is not eligible.", + "pricing": "free", + "pricing_note": "A basic profile is free; paid AdVantage and premium upgrades provide additional visibility.", + "link_type": "nofollow", + "domain_rating": 81, + "monthly_traffic": 9500, + "categories": [ + "business", + "community" + ] + }, + { + "slug": "how-brands-are-built", + "name": "How Brands Are Built", + "type": "citation", + "website_url": "https://howbrandsarebuilt.com/", + "submission_url": "https://howbrandsarebuilt.com/submit/", + "logo_url": "https://www.google.com/s2/favicons?domain=howbrandsarebuilt.com&sz=64", + "description": "How Brands Are Built is a branding blog and podcast about brand strategy, naming, architecture, messaging, and visual identity.", + "audience": "Branding practitioners and founders with original, substantive expertise about building brands.", + "eligibility": "Submissions must be original content directly or closely related to building brands; the publication typically rejects social-media marketing, influencer marketing, personal-branding, and SEO topics.", + "pricing": "free", + "pricing_note": "Editorial post submissions use a free form; sponsored posts are available but no public rate card is published.", + "link_type": "dofollow", + "domain_rating": 47, + "monthly_traffic": 3000, + "categories": [ + "business", + "marketing", + "startup" + ] + }, + { + "slug": "hubspot-app-marketplace", + "name": "HubSpot App Marketplace", + "type": "marketplace", + "website_url": "https://ecosystem.hubspot.com/marketplace/apps/", + "submission_url": "https://developers.hubspot.com/docs/apps/developer-platform/list-apps/listing-your-app/app-marketplace-listing-requirements", + "logo_url": "https://www.google.com/s2/favicons?domain=ecosystem.hubspot.com&sz=64", + "description": "HubSpot's marketplace for applications and integrations that extend its CRM, marketing, sales, service, content, operations, and commerce products.", + "audience": "Developers with a public OAuth integration used by multiple independent HubSpot customers.", + "eligibility": "The app must be public, use OAuth, comply with HubSpot requirements, and have at least three active installs from separate unaffiliated accounts before listing review.", + "pricing": "free", + "pricing_note": "HubSpot does not state an App Marketplace listing fee in its publishing requirements. Building, hosting, supporting, and selling the integration remain the publisher's responsibility.", + "link_type": "dofollow", + "domain_rating": 93, + "monthly_traffic": 71100, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "huffpost", + "name": "HuffPost", + "type": "citation", + "website_url": "https://www.huffpost.com", + "submission_url": "https://www.huffpost.com/static/how-to-pitch-huffpost", + "logo_url": "https://www.google.com/s2/favicons?domain=huffpost.com&sz=64", + "description": "HuffPost is a digital news and opinion publication covering politics, culture, lifestyle, personal stories, and social issues.", + "audience": "Writers and organizations with original, newsworthy reporting or authentic stories that fit a HuffPost section; not a self-promotional startup-listing channel.", + "eligibility": "Pitches need a strong headline, concise outline, reporting plan, and author background for an active editorial section. HuffPost does not accept PR or agent pitches for promotional startup listings.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 92, + "monthly_traffic": 1400000, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "huggingface", + "name": "Hugging Face", + "type": "community", + "website_url": "https://huggingface.co", + "submission_url": "https://huggingface.co/new-space", + "logo_url": "https://huggingface.co/favicon.ico", + "description": "AI community and hub for publishing models, datasets, and interactive AI applications as Spaces.", + "audience": "AI developers and teams with models, datasets, or demonstrable AI applications.", + "eligibility": "AI projects that can be published as a model, dataset, or interactive Space. This is not a general external-link directory; a concrete Hub artifact is required.", + "pricing": "free", + "pricing_note": "Public models, datasets and basic Spaces are free, with paid upgrades for PRO, storage, hardware or private features.", + "link_type": "nofollow", + "domain_rating": 91, + "monthly_traffic": 1500000, + "categories": [ + "ai", + "developer-tools" + ] + }, + { + "slug": "inc42", + "name": "Inc42", + "type": "citation", + "website_url": "https://inc42.com/", + "submission_url": "https://inc42.com/startup-submission/", + "logo_url": "https://www.google.com/s2/favicons?domain=inc42.com&sz=64", + "description": "Inc42 is an Indian media and data platform covering the country's technology and startup ecosystem with news, features, profiles, and intelligence tools.", + "audience": "India-focused technology startups with a working product, a relevant startup story, and evidence of market progress.", + "eligibility": "Startup Spotlight applicants must be India-focused, independent, technology-led businesses with a working MVP, market relevance, and revenue or a near-term launch; editorial selection is highly selective.", + "pricing": "free", + "pricing_note": "The Startup Spotlight application is free; optional paid booster packages are separate from editorial selection.", + "link_type": "nofollow", + "domain_rating": 81, + "monthly_traffic": 64200, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "indie-hackers", + "name": "Indie Hackers", + "type": "community", + "website_url": "https://www.indiehackers.com/", + "submission_url": "https://www.indiehackers.com/products", + "logo_url": "https://www.google.com/s2/favicons?domain=indiehackers.com&sz=64", + "description": "Indie Hackers is a community where independent founders and makers share milestones, strategies, and products for building online businesses.", + "audience": "Independent founders, bootstrapped makers, and product builders who can share a real product and participate constructively in the community.", + "eligibility": "Product listings are open to makers, while community participation expects genuine build-in-public updates, transparency, and useful discussion rather than pure advertising.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 81, + "monthly_traffic": 126000, + "categories": [ + "indie", + "startup" + ] + }, + { + "slug": "indiegames", + "name": "r/indiegames", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/indiegames/", + "submission_url": "https://www.reddit.com/r/indiegames/submit", + "logo_url": "https://styles.redditmedia.com/t5_2qrf3/styles/communityIcon_6uj2hqkj0joe1.jpeg?width=256&s=4b2b4e3ad4bc61cf6338eabbc0e9b0e4dad5fc3c", + "description": "Anything related to indie games", + "audience": "Indie game developers and players showcasing finished games and gameplay.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 331406, + "growth_30d_absolute": 7105, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/indiegames/about/rules" + } + }, + { + "slug": "indiegaming", + "name": "r/IndieGaming", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/IndieGaming/", + "submission_url": "https://www.reddit.com/r/IndieGaming/submit", + "logo_url": "https://b.thumbs.redditmedia.com/kxY2vmdvlHiDNCLzq1mc51czUxD_VHBPrlZ-GXsTXVc.png", + "description": "A place for indie games", + "audience": "Indie game developers and players sharing games, development progress, and original media.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 519366, + "growth_30d_absolute": 10037, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/IndieGaming/about/rules" + } + }, + { + "slug": "indiehackers", + "name": "r/indiehackers", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/indiehackers/", + "submission_url": "https://www.reddit.com/r/indiehackers/submit", + "logo_url": "https://styles.redditmedia.com/t5_3gjm0/styles/communityIcon_l6l5fyl07eud1.png?width=256&s=c48aa5ae40d197651d74c22ca9e8da20a995f52a", + "description": "IndieHackers is a subreddit focused on people who bootstrap their way to success by building products.", + "audience": "Indie founders and builders bootstrapping products and seeking feedback or critique.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "indie", + "saas", + "startup" + ], + "subreddit": { + "members_count": 189657, + "growth_30d_absolute": 5448, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/indiehackers/about/rules" + } + }, + { + "slug": "iosapps", + "name": "r/iosapps", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/iosapps/", + "submission_url": "https://www.reddit.com/r/iosapps/submit", + "logo_url": "https://styles.redditmedia.com/t5_2s54e/styles/communityIcon_9q8wwxl6apic1.png?width=256&s=77bbcee451e93f1e0566b970966f339afcfaffbc", + "description": "r/iOSApps is a one stop shop for all things related to iOS apps - featuring app showcases, reviews, news, updates, sales, developers, discounts and even freebies.", + "audience": "iOS app developers and users discovering and discussing App Store and TestFlight apps.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 121920, + "growth_30d_absolute": 5153, + "promotion_mode": "direct_post+promotion_thread", + "rules_url": "https://www.reddit.com/r/iosapps/about/rules" + } + }, + { + "slug": "iosprogramming", + "name": "r/iOSProgramming", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/iOSProgramming/", + "submission_url": "https://www.reddit.com/r/iOSProgramming/submit", + "logo_url": "https://styles.redditmedia.com/t5_2s61a/styles/communityIcon_c25c0en0kb6f1.png?width=256&s=daf0625a968cf7cc423d04e815c571e14008c848", + "description": "A subreddit to discuss, share articles, code samples, open source projects and anything else related to iOS, macOS, watchOS, tvOS, or visionOS development.", + "audience": "Apple-platform developers sharing qualifying app projects.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 204762, + "growth_30d_absolute": 2246, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/iOSProgramming/about/rules" + } + }, + { + "slug": "jasmine-directory", + "name": "Jasmine Directory", + "type": "directory", + "website_url": "https://www.jasminedirectory.com/", + "submission_url": "https://www.jasminedirectory.com/index.php?a=2070753691", + "logo_url": "https://www.google.com/s2/favicons?domain=jasminedirectory.com&sz=64", + "description": "Jasmine Directory is a human-curated web directory of businesses and websites with editorial review.", + "audience": "Owners of legitimate businesses and websites that fit a topical or regional category.", + "eligibility": "Listings must be useful, non-spam websites or businesses that fit a category and pass editorial quality and relevance review.", + "pricing": "paid", + "pricing_note": "Standard costs US$59 per year, Express US$79 one time, and Ultimate US$149 one time; the fee is refunded if the listing is not accepted.", + "link_type": "dofollow", + "domain_rating": 67, + "monthly_traffic": 4000, + "categories": [ + "business" + ] + }, + { + "slug": "javascript", + "name": "r/javascript", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/javascript/", + "submission_url": "https://www.reddit.com/r/javascript/submit", + "logo_url": "https://a.thumbs.redditmedia.com/zDOFJTXd6fmlD58VDGypiV94Leflz11woxmgbGY6p_4.png", + "description": "Chat about javascript and javascript related projects. Yes, typescript counts. Please keep self promotion to a minimum/reasonable level.", + "audience": "JavaScript developers sharing relevant content and projects.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 2450107, + "growth_30d_absolute": 3567, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/javascript/about/rules" + } + }, + { + "slug": "lachief", + "name": "Lachief", + "type": "directory", + "website_url": "https://www.lachief.io/", + "submission_url": "https://tally.so/r/w4Jb4b", + "logo_url": "https://www.google.com/s2/favicons?domain=lachief.io&sz=64", + "description": "AI tool directory for discovering software across writing, productivity, marketing, design, development, and other use cases.", + "audience": "AI software makers seeking discovery among people comparing tools for creative, business, technical, and productivity workflows.", + "eligibility": "Submit one listing per tool, provide accurate product details and media, and choose relevant categories. Lachief reviews free submissions before publication.", + "pricing": "free", + "pricing_note": "A standard listing is free and may take up to seven days to publish after approval. An optional seven-day featured listing costs $250.", + "link_type": "unknown", + "domain_rating": 20, + "monthly_traffic": 11400, + "categories": [ + "ai" + ] + }, + { + "slug": "landbook", + "name": "Landbook", + "type": "directory", + "website_url": "https://land-book.com/", + "submission_url": "https://land-book.com/submission-guidelines", + "logo_url": "https://www.google.com/s2/favicons?domain=land-book.com&sz=64", + "description": "Landbook is a curated, daily updated gallery of hand-picked website designs for inspiration among creatives, designers, and marketers.", + "audience": "Designers, agencies, and teams with a high-quality public website that merits design-gallery consideration.", + "eligibility": "Anyone may submit, but only curated high-quality sites are featured after review of design trends, aesthetics, usability, accessibility, and content.", + "pricing": "free", + "pricing_note": "Website submissions are free; templates can have a paid feature step after approval.", + "link_type": "dofollow", + "domain_rating": 73, + "monthly_traffic": 13700, + "categories": [ + "design" + ] + }, + { + "slug": "landingfolio", + "name": "Landingfolio", + "type": "directory", + "website_url": "https://landingfolio.com/", + "submission_url": "https://landingfolio.com/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=landingfolio.com&sz=64", + "description": "Curated design gallery and component library showcasing high-quality landing pages, page sections, flows, and visual patterns for product and marketing inspiration.", + "audience": "Product designers, marketers, founders, and developers researching polished landing-page examples and reusable interface patterns.", + "eligibility": "Submissions must be live pages for real products with strong typography, color, layout, mobile behavior, and loading performance; template demos, placeholder copy, broken pages, and unfinished work are rejected.", + "pricing": "paid", + "pricing_note": "Submission is a member perk; membership is listed at $1.99 per week or $59 for lifetime access.", + "link_type": "nofollow", + "domain_rating": 64, + "monthly_traffic": 29800, + "categories": [ + "design", + "marketing" + ] + }, + { + "slug": "launch-vault", + "name": "Launch Vault", + "type": "product-launch-site", + "website_url": "https://www.launchvault.dev/", + "submission_url": "https://www.launchvault.dev/projects/submit", + "logo_url": "https://www.launchvault.dev/logo.svg", + "description": "Curated daily product-launch platform where makers publish technology products, receive votes, and compete for discovery placements.", + "audience": "Indie makers and SaaS founders seeking a permanent product page and launch-day exposure to people browsing newly launched software.", + "eligibility": "Create an account and submit a legitimate technology product with complete, accurate launch details. Use the premium launch path; the free path requires a reciprocal dofollow backlink and is not recommended here.", + "pricing": "paid", + "pricing_note": "Premium launch is advertised at $9.99 per launch and does not require a backlink. The $0 route requires a verified dofollow backlink and is excluded from this recommendation.", + "link_type": "unknown", + "domain_rating": 55, + "monthly_traffic": 1100, + "categories": [ + "saas", + "startup" + ] + }, + { + "slug": "launchmystartup", + "name": "r/LaunchMyStartup", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/LaunchMyStartup/", + "submission_url": "https://www.reddit.com/r/LaunchMyStartup/submit", + "logo_url": "https://styles.redditmedia.com/t5_dd6wep/styles/communityIcon_n4rcuezzubde1.jpg?width=256&s=9730173836bf626f5ae8db0d3617e8f0a27bd364", + "description": "Welcome to LaunchMyStartup! This is where founders launch their cool new projects and get real feedback from other builders. No BS, no gatekeeping - just a place to show what you've made.\nGot something brewing? Share it here. Looking for the next big thing? You'll find fresh projects from indie makers, startups, and solo founders.\nJoin us to discover what founders are building or launch your own thing. Let's grow together.", + "audience": "Founders launching new projects and seeking community engagement.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "indie", + "startup" + ], + "subreddit": { + "members_count": 8359, + "growth_30d_absolute": 195, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/LaunchMyStartup/about/rules" + } + }, + { + "slug": "letterranks-1440", + "name": "1440", + "type": "newsletter", + "website_url": "https://join1440.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=join1440.com&sz=64", + "description": "1440 is a daily news digest that curates fact-driven, unbiased news briefs across politics, business, science, and culture without opinion or clickbait.", + "audience": "Its readership spans intellectually curious individuals and readers across the political spectrum seeking impartial, facts-focused news summaries.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "leadership", + "platform": "beehiiv", + "subscribers": 3500000, + "open_rate": 45 + } + }, + { + "slug": "letterranks-alex-hormozi-s-newsletter", + "name": "Alex Hormozi's Newsletter", + "type": "newsletter", + "website_url": "https://acquisition.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=acquisition.com&sz=64", + "description": "Alex Hormozi's newsletter, The Mozi Money Minute, delivers brief, actionable business strategies and tools designed to boost company revenue.", + "audience": "It targets entrepreneurs and business owners seeking practical tactics to scale their businesses efficiently.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "solopreneur", + "platform": "beehiiv", + "subscribers": 500000, + "open_rate": 42 + } + }, + { + "slug": "letterranks-benedict-s-newsletter", + "name": "Benedict's Newsletter", + "type": "newsletter", + "website_url": "https://www.ben-evans.com/newsletter", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=ben-evans.com&sz=64", + "description": "Benedict's Newsletter is Benedict Evans's analysis of technology, consumer products, and the long-term changes affecting the industry.", + "audience": "Its readers include technology executives, investors, product leaders, and professionals who follow strategic technology trends.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "product", + "platform": "substack", + "subscribers": 175000, + "open_rate": 42 + } + }, + { + "slug": "letterranks-bytebytego", + "name": "ByteByteGo", + "type": "newsletter", + "website_url": "https://blog.bytebytego.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=blog.bytebytego.com&sz=64", + "description": "ByteByteGo is a newsletter that breaks down complex software architecture and distributed system design concepts into accessible explanations.", + "audience": "It targets software engineers, developers, and technical leaders seeking to enhance their system design skills.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "programming", + "platform": "substack", + "subscribers": 500000, + "open_rate": 42 + } + }, + { + "slug": "letterranks-cb-insights", + "name": "CB Insights", + "type": "newsletter", + "website_url": "https://cbinsights.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=cbinsights.com&sz=64", + "description": "The CB Insights Newsletter provides data-driven rundowns on emerging tech trends, venture capital funding, and private company markets.", + "audience": "It targets business leaders, corporate strategists, investors, and tech professionals looking to stay ahead of market movements.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "startups", + "platform": "other", + "subscribers": 800000, + "open_rate": 35 + } + }, + { + "slug": "letterranks-demand-curve", + "name": "Demand Curve", + "type": "newsletter", + "website_url": "https://demandcurve.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=demandcurve.com&sz=64", + "description": "Demand Curve is a growth education and resources company whose newsletter shares acquisition, marketing, and startup growth tactics.", + "audience": "It serves startup founders, growth marketers, and early-stage teams building repeatable customer acquisition systems.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "marketing", + "topic": "growth", + "platform": "other", + "subscribers": 95000, + "open_rate": 42 + } + }, + { + "slug": "letterranks-dense-discovery", + "name": "Dense Discovery", + "type": "newsletter", + "website_url": "https://densediscovery.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=densediscovery.com&sz=64", + "description": "Dense Discovery is a weekly newsletter featuring thoughtful recommendations across design, technology, culture, and digital products.", + "audience": "It is aimed at designers, creative professionals, and digitally minded readers seeking considered tools and discoveries.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "product", + "platform": "ghost", + "subscribers": 42000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-exec-sum", + "name": "Exec Sum", + "type": "newsletter", + "website_url": "https://execsum.co", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=execsum.co&sz=64", + "description": "Exec Sum is a daily newsletter curating financial market news, corporate trends, and tech industry updates from Wall Street to Silicon Valley.", + "audience": "It targets investment bankers, institutional investors, venture capitalists, and financial services professionals.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "leadership", + "platform": "beehiiv", + "subscribers": 180000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-growth-in-reverse", + "name": "Growth in Reverse", + "type": "newsletter", + "website_url": "https://growthinreverse.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=growthinreverse.com&sz=64", + "description": "Growth in Reverse analyzes how successful companies and creators grew, turning their public histories into practical growth lessons.", + "audience": "It targets founders, marketers, and growth operators looking for concrete examples of distribution and business-building strategy.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "marketing", + "topic": "growth", + "platform": "beehiiv", + "subscribers": 42000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-hacker-newsletter", + "name": "Hacker Newsletter", + "type": "newsletter", + "website_url": "https://hackernewsletter.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=hackernewsletter.com&sz=64", + "description": "Hacker Newsletter is a curated newsletter highlighting notable links and stories for people interested in software and technology.", + "audience": "It serves developers, engineers, and technology enthusiasts who want a concise selection of useful technical reading.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "programming", + "platform": "mailchimp", + "subscribers": 60000, + "open_rate": 45 + } + }, + { + "slug": "letterranks-jay-clouse-s-creator-science", + "name": "Jay Clouse's Creator Science", + "type": "newsletter", + "website_url": "https://creatorscience.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=creatorscience.com&sz=64", + "description": "Creator Science is Jay Clouse's newsletter about building a sustainable creator business through strategy, experimentation, and audience development.", + "audience": "It is aimed at independent creators, educators, and online business owners who want to grow a durable creative practice.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "solopreneur", + "platform": "kit", + "subscribers": 55000, + "open_rate": 50 + } + }, + { + "slug": "letterranks-lenny-s-newsletter", + "name": "Lenny's Newsletter", + "type": "newsletter", + "website_url": "https://lennysnewsletter.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=lennysnewsletter.com&sz=64", + "description": "Lenny's Newsletter is an advice column by Lenny Rachitsky offering researched insights on product development, growth strategy, and career progression.", + "audience": "The newsletter is aimed at product leaders, startup founders, and ambitious builders in technology.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "product", + "platform": "substack", + "subscribers": 650000, + "open_rate": 45 + } + }, + { + "slug": "letterranks-morning-brew", + "name": "Morning Brew", + "type": "newsletter", + "website_url": "https://morningbrew.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=morningbrew.com&sz=64", + "description": "Morning Brew is a daily email newsletter delivering concise updates on global business, finance, and technology news with a conversational, humorous tone.", + "audience": "Its audience consists of business professionals, career-focused individuals, and readers seeking accessible insights into the economy and industry trends.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "leadership", + "platform": "other", + "subscribers": 4000000, + "open_rate": 42 + } + }, + { + "slug": "letterranks-nathan-barry-s-newsletter", + "name": "Nathan Barry's Newsletter", + "type": "newsletter", + "website_url": "https://nathanbarry.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=nathanbarry.com&sz=64", + "description": "Nathan Barry's Newsletter shares Nathan Barry's writing on entrepreneurship, creator businesses, marketing, and building products.", + "audience": "It is intended for founders, creators, and independent operators interested in growing an internet business.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "solopreneur", + "platform": "kit", + "subscribers": 45000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-newcomer", + "name": "Newcomer", + "type": "newsletter", + "website_url": "https://newcomer.co", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=newcomer.co&sz=64", + "description": "Newcomer is a newsletter covering venture capital, startups, and the people and firms shaping the technology industry.", + "audience": "It targets founders, venture capital professionals, startup operators, and readers tracking the technology business.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "startups", + "platform": "substack", + "subscribers": 55000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-not-boring", + "name": "Not Boring", + "type": "newsletter", + "website_url": "https://notboring.co", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=notboring.co&sz=64", + "description": "Not Boring is a newsletter by Packy McCormick offering in-depth analyses of business models, tech trends, and ambitious startup stories.", + "audience": "It targets founders, investors, and tech enthusiasts interested in complex companies and frontier innovations.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "startups", + "platform": "substack", + "subscribers": 225000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-pat-walls-starter-story", + "name": "Pat Walls' Starter Story", + "type": "newsletter", + "website_url": "https://starterstory.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=starterstory.com&sz=64", + "description": "Starter Story is a publication and community sharing interviews and lessons from entrepreneurs who have built businesses.", + "audience": "It is useful to aspiring founders, entrepreneurs, and operators looking for practical examples of business growth and execution.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "solopreneur", + "platform": "kit", + "subscribers": 85000, + "open_rate": 45 + } + }, + { + "slug": "letterranks-platformer", + "name": "Platformer", + "type": "newsletter", + "website_url": "https://platformer.news", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=platformer.news&sz=64", + "description": "Platformer is Casey Newton's newsletter about the intersection of technology, policy, and society, with a focus on the platforms shaping public life.", + "audience": "It serves technology professionals, policymakers, journalists, and readers following the social and political impact of major platforms.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "product", + "platform": "substack", + "subscribers": 120000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-pointer", + "name": "Pointer", + "type": "newsletter", + "website_url": "https://pointer.io", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=pointer.io&sz=64", + "description": "Pointer is a newsletter curating useful articles, tools, and ideas for software developers and technology practitioners.", + "audience": "It is intended for developers and technical readers who want a concise selection of current programming and technology resources.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "programming", + "platform": "buttondown", + "subscribers": 35000, + "open_rate": 40 + } + }, + { + "slug": "letterranks-sahil-bloom-s-newsletter", + "name": "Sahil Bloom's Newsletter", + "type": "newsletter", + "website_url": "https://sahilbloom.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=sahilbloom.com&sz=64", + "description": "The Curiosity Chronicle is a newsletter by Sahil Bloom that delivers actionable ideas and deep dives focused on personal growth, health, wealth, and living better.", + "audience": "It targets growth-minded individuals seeking practical insights to think deeper and build a high-performing life.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "solopreneur", + "platform": "beehiiv", + "subscribers": 700000, + "open_rate": 45 + } + }, + { + "slug": "letterranks-sidebar", + "name": "Sidebar", + "type": "newsletter", + "website_url": "https://sidebar.io", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=sidebar.io&sz=64", + "description": "Sidebar is a daily newsletter curating noteworthy links and reading across technology, design, and the wider web.", + "audience": "It is for technology professionals, designers, and curious readers who want a compact daily reading list.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "product", + "platform": "ghost", + "subscribers": 50000, + "open_rate": 45 + } + }, + { + "slug": "letterranks-software-lead-weekly", + "name": "Software Lead Weekly", + "type": "newsletter", + "website_url": "https://softwareleadweekly.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=softwareleadweekly.com&sz=64", + "description": "Software Lead Weekly is a curated newsletter of software engineering articles, tools, and ideas for people leading technical teams.", + "audience": "It serves software engineers, engineering managers, and technical leaders who want a focused weekly reading list.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "programming", + "platform": "kit", + "subscribers": 38000, + "open_rate": 42 + } + }, + { + "slug": "letterranks-sparktoro-office-hours", + "name": "SparkToro Office Hours", + "type": "newsletter", + "website_url": "https://sparktoro.com/blog", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=sparktoro.com&sz=64", + "description": "SparkToro Office Hours is a newsletter and content series from SparkToro covering audience research, marketing, and growth strategy.", + "audience": "It is aimed at marketers, founders, and audience-development professionals who want practical research and distribution guidance.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "marketing", + "topic": "growth", + "platform": "kit", + "subscribers": 45000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-stratechery", + "name": "Stratechery", + "type": "newsletter", + "website_url": "https://stratechery.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=stratechery.com&sz=64", + "description": "Stratechery is a publication by Ben Thompson providing analysis on the business, strategy, and societal impact of technology and media.", + "audience": "Its audience includes executives in technology and tech-impacted industries, venture capitalists, investors, and readers interested in tech business strategy.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "product", + "platform": "other", + "subscribers": 150000, + "open_rate": 55 + } + }, + { + "slug": "letterranks-the-daily-upside", + "name": "The Daily Upside", + "type": "newsletter", + "website_url": "https://thedailyupside.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=thedailyupside.com&sz=64", + "description": "The Daily Upside is a modern business media brand and daily newsletter delivering news, market analysis, and insights on finance, economics, and global markets.", + "audience": "Its audience consists of seasoned Wall Street professionals, business leaders, investors, and executives seeking clarity on market developments and economic trends.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "leadership", + "platform": "beehiiv", + "subscribers": 550000, + "open_rate": 45 + } + }, + { + "slug": "letterranks-the-diff", + "name": "The Diff", + "type": "newsletter", + "website_url": "https://diff.substack.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=diff.substack.com&sz=64", + "description": "The Diff is a newsletter by Byrne Hobart covering technology, business, markets, and the ideas shaping startups.", + "audience": "It is aimed at founders, investors, operators, and readers who want analytical context on technology and business.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "startups", + "platform": "substack", + "subscribers": 65000, + "open_rate": 50 + } + }, + { + "slug": "letterranks-the-generalist", + "name": "The Generalist", + "type": "newsletter", + "website_url": "https://generalist.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=generalist.com&sz=64", + "description": "The Generalist is a publication by Mario Gabriele delivering deep dives on startups, technology, business, and ambitious companies.", + "audience": "Its audience includes founders, investors, operators, and technology readers interested in how important companies are built.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "startups", + "platform": "substack", + "subscribers": 85000, + "open_rate": 45 + } + }, + { + "slug": "letterranks-the-information", + "name": "The Information", + "type": "newsletter", + "website_url": "https://theinformation.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=theinformation.com&sz=64", + "description": "The Information is a digital news publication delivering original, deeply reported coverage of the technology and business sectors.", + "audience": "Its subscriber base primarily consists of startup founders, venture capitalists, and technology decision-makers.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "startups", + "platform": "other", + "subscribers": 400000, + "open_rate": 45 + } + }, + { + "slug": "letterranks-the-pragmatic-engineer", + "name": "The Pragmatic Engineer", + "type": "newsletter", + "website_url": "https://pragmaticengineer.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=pragmaticengineer.com&sz=64", + "description": "The Pragmatic Engineer is a publication authored by Gergely Orosz offering inside coverage of software engineering practices, startups, and big tech companies.", + "audience": "Its primary audience includes software engineers, engineering managers, and technical leaders across the tech industry.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "programming", + "platform": "substack", + "subscribers": 550000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-the-profile", + "name": "The Profile", + "type": "newsletter", + "website_url": "https://www.readtheprofile.com/", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=theprofile.substack.com&sz=64", + "description": "The Profile is a weekly newsletter and media publication authored by Polina Pompliano featuring in-depth profiles of extraordinary founders, investors, executives, and leaders.", + "audience": "It is read by founders, investors, and operators interested in studying high performers and the psychology of success.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "leadership", + "platform": "substack", + "subscribers": 110000, + "open_rate": 50 + } + }, + { + "slug": "letterranks-the-rebooting", + "name": "The Rebooting", + "type": "newsletter", + "website_url": "https://therebooting.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=therebooting.com&sz=64", + "description": "The Rebooting is a publication and podcast by Brian Morrissey exploring the business models, audience strategy, and reinvention of modern media.", + "audience": "It serves media executives, digital publishers, audience development leaders, and media business operators.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "leadership", + "platform": "ghost", + "subscribers": 35000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-tldr", + "name": "TLDR", + "type": "newsletter", + "website_url": "https://tldr.tech", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=tldr.tech&sz=64", + "description": "TLDR is a daily email newsletter providing concise summaries of key developments across startups, technology, and programming.", + "audience": "The newsletter targets tech professionals, including developers, AI builders, product managers, marketers, designers, and executives.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "programming", + "platform": "beehiiv", + "subscribers": 1250000, + "open_rate": 52 + } + }, + { + "slug": "letterranks-tldr-design", + "name": "TLDR Design", + "type": "newsletter", + "website_url": "https://tldr.tech/design", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=tldr.tech&sz=64", + "description": "TLDR Design is a TLDR edition covering product design, user experience, and the tools and ideas relevant to modern designers.", + "audience": "It is aimed at product designers, UX practitioners, and builders who want a concise design and technology briefing.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "product", + "platform": "beehiiv", + "subscribers": 180000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-tldr-founders", + "name": "TLDR Founders", + "type": "newsletter", + "website_url": "https://tldr.tech/founders", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=tldr.tech&sz=64", + "description": "TLDR Founders is a TLDR edition focused on startup companies, entrepreneurship, and the decisions involved in building a business.", + "audience": "It serves startup founders, entrepreneurs, and operators looking for a concise view of the technology and business landscape.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "startups", + "platform": "beehiiv", + "subscribers": 150000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-tldr-marketing", + "name": "TLDR Marketing", + "type": "newsletter", + "website_url": "https://tldr.tech/marketing", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=tldr.tech&sz=64", + "description": "TLDR Marketing is a TLDR edition covering growth, customer acquisition, marketing strategy, and the tools used by modern teams.", + "audience": "It targets marketers, growth practitioners, founders, and operators responsible for attracting and retaining customers.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "marketing", + "topic": "growth", + "platform": "beehiiv", + "subscribers": 120000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-tldr-web-dev", + "name": "TLDR Web Dev", + "type": "newsletter", + "website_url": "https://tldr.tech/webdev", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=tldr.tech&sz=64", + "description": "TLDR Web Dev is a focused edition of TLDR covering software development, web technologies, and practical programming news.", + "audience": "It is intended for web developers, software engineers, and technical builders who want a concise programming briefing.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "tech", + "topic": "programming", + "platform": "beehiiv", + "subscribers": 220000, + "open_rate": 48 + } + }, + { + "slug": "letterranks-workweek", + "name": "Workweek", + "type": "newsletter", + "website_url": "https://workweek.com", + "submission_url": null, + "logo_url": "https://www.google.com/s2/favicons?domain=workweek.com&sz=64", + "description": "Workweek is a business media network of creator-experts delivering industry-focused newsletters, podcasts, and digital content.", + "audience": "It targets business leaders, executives, and industry operators across fields such as marketing, fintech, healthcare, HR, and ecommerce.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": null, + "monthly_traffic": null, + "categories": [], + "newsletter": { + "category": "business", + "topic": "leadership", + "platform": "beehiiv", + "subscribers": 200000, + "open_rate": 45 + } + }, + { + "slug": "libhunt", + "name": "LibHunt", + "type": "citation", + "website_url": "https://www.libhunt.com/", + "submission_url": "https://www.libhunt.com/site/project_submit", + "logo_url": "https://www.google.com/s2/favicons?domain=libhunt.com&sz=64", + "description": "LibHunt surfaces popular and alternative open-source libraries and projects by tracking social-network mentions for developers.", + "audience": "Maintainers of open-source libraries and projects seeking developer discovery.", + "eligibility": "Contributions must be open-source projects with a public GitHub or GitLab repository and be suggested as an alternative to a relevant existing LibHunt project.", + "pricing": "free", + "pricing_note": "Project contributions are free; paid advertising packages are separate.", + "link_type": "dofollow", + "domain_rating": 66, + "monthly_traffic": 2000, + "categories": [ + "community", + "developer-tools" + ] + }, + { + "slug": "llm-relevance", + "name": "LLM Relevance", + "type": "directory", + "website_url": "https://www.llmrelevance.com/", + "submission_url": "https://www.llmrelevance.com/submit", + "logo_url": "https://www.llmrelevance.com/favicon/android-chrome-192x192.png", + "description": "AI and SEO tool directory for discovering software, comparing use cases, and browsing free tools for small-business marketing workflows.", + "audience": "AI software makers seeking discovery among small-business owners, marketers, and operators researching AI and SEO tools.", + "eligibility": "Submit a functioning AI or LLM-related tool with its public URL, category, pricing, maker name, and contact email. Listings are reviewed before approval; adding the displayed directory badge is optional.", + "pricing": "free", + "pricing_note": "The submission page describes the standard directory listing as free and does not make its optional badge a condition of review.", + "link_type": "unknown", + "domain_rating": 38, + "monthly_traffic": 1000, + "categories": [ + "ai", + "marketing" + ] + }, + { + "slug": "lobsters", + "name": "Lobsters", + "type": "citation", + "website_url": "https://lobste.rs/", + "submission_url": "https://lobste.rs/about", + "logo_url": "https://www.google.com/s2/favicons?domain=lobste.rs&sz=64", + "description": "Lobsters is an invite-only, computing-focused link aggregation and discussion site where members can submit relevant technical stories.", + "audience": "Technical product teams with a relevant, on-topic computing story who can earn an invitation from an existing member.", + "eligibility": "Members must receive an invitation from an existing member; stories must be computing-focused and on-topic, self-promotion should remain under roughly 25% of a member's stories and comments, and new users have extra restrictions for 70 days.", + "pricing": "free", + "pricing_note": "Free; there is no listing fee, but membership is invite-only.", + "link_type": "dofollow", + "domain_rating": 75, + "monthly_traffic": 3000, + "categories": [ + "community", + "developer-tools" + ] + }, + { + "slug": "macstories", + "name": "MacStories", + "type": "citation", + "website_url": "https://www.macstories.net/", + "submission_url": "https://www.macstories.net/about/", + "logo_url": "https://www.google.com/s2/favicons?domain=macstories.net&sz=64", + "description": "MacStories is an independent daily publication focused on Apple, apps, automation, iOS and macOS productivity, and related technology.", + "audience": "Apple, Mac, iOS, and app makers with a product of genuine interest to the publication's audience.", + "eligibility": "Editorial coverage depends on an Apple, Mac, iOS, or app product being genuinely relevant to the audience and independently tested by the team; paid reviews are not accepted.", + "pricing": "free", + "pricing_note": "Editorial consideration is free when selected; homepage sponsorships and other paid placements are separately available by inquiry, and paid reviews are not accepted.", + "link_type": "dofollow", + "domain_rating": 77, + "monthly_traffic": 17200, + "categories": [ + "business", + "productivity" + ] + }, + { + "slug": "made-with-vuejs", + "name": "Made With Vue.js", + "type": "citation", + "website_url": "https://madewithvuejs.com/", + "submission_url": "https://madewithvuejs.com/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=madewithvuejs.com&sz=64", + "description": "Made With Vue.js is a curated showcase of Vue.js projects, websites, UI components, frameworks, apps, tools, and resources for the Vue community.", + "audience": "Creators of actively maintained, interesting Vue.js projects, websites, components, frameworks, apps, tools, and resources.", + "eligibility": "Submissions must be interesting, inspirational, and continuously supported Vue.js projects or resources that pass personal editorial review; paid placement does not guarantee acceptance.", + "pricing": "free", + "pricing_note": "Free submissions are reviewed personally and typically take 6–8 weeks; paid placement offers faster or timely featuring, but selection is still not guaranteed and refunds apply if it is not added.", + "link_type": "dofollow", + "domain_rating": 60, + "monthly_traffic": 6700, + "categories": [ + "community", + "design", + "developer-tools" + ] + }, + { + "slug": "make-app-directory", + "name": "Make App Directory", + "type": "marketplace", + "website_url": "https://www.make.com/en/integrations", + "submission_url": "https://www.make.com/en/technology-partners", + "logo_url": "https://www.google.com/s2/favicons?domain=make.com&sz=64", + "description": "Make is an automation platform with an app directory and partner programs for integration builders and certified automation experts.", + "audience": "Technology companies with a verified public Make app or automation consultancies that meet Make's partner requirements.", + "eligibility": "Technology partners need a verified public Make app, while solution partners need relevant services experience, trained experts, and an accepted partner agreement.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 90, + "monthly_traffic": 299900, + "categories": [ + "productivity", + "saas" + ] + }, + { + "slug": "makeuseof", + "name": "MakeUseOf", + "type": "citation", + "website_url": "https://www.makeuseof.com", + "submission_url": "https://www.makeuseof.com/page/press-events/", + "logo_url": "https://www.google.com/s2/favicons?domain=makeuseof.com&sz=64", + "description": "MakeUseOf is a technology publication that covers practical technology education, recommendations, and product reviews.", + "audience": "Products and brands whose practical technology value aligns with MakeUseOf's reader-focused editorial mission.", + "eligibility": "MakeUseOf independently selects relevant products and collaborations based on reader value and does not offer a public self-serve product directory.", + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 87, + "monthly_traffic": 289700, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "marketingprofs", + "name": "MarketingProfs", + "type": "citation", + "website_url": "https://www.marketingprofs.com/", + "submission_url": "https://www.marketingprofs.com/write-for-us", + "logo_url": "https://www.google.com/s2/favicons?domain=marketingprofs.com&sz=64", + "description": "MarketingProfs publishes articles, training, events, and community resources for B2B marketers.", + "audience": "B2B and enterprise marketing practitioners who can contribute objective, practical, non-promotional expertise.", + "eligibility": "Bylined articles must be original, unpublished, actionable marketing content that meets MarketingProfs editorial standards; the free contribution route is selective.", + "pricing": "free", + "pricing_note": "Editorial contributions are free and selective; sponsored publication options are separately paid.", + "link_type": "dofollow", + "domain_rating": 83, + "monthly_traffic": 18500, + "categories": [ + "business", + "marketing" + ] + }, + { + "slug": "mars-discovery-district", + "name": "MaRS Discovery District", + "type": "citation", + "website_url": "https://app.marsdd.com/", + "submission_url": "https://app.marsdd.com/signup", + "logo_url": "https://www.google.com/s2/favicons?domain=app.marsdd.com&sz=64", + "description": "MaRS Discovery District is a Canadian innovation hub that supports selected science and technology startups with advisory services, networks, programs, and a companies directory.", + "audience": "Incorporated Canadian founders building innovative climate, health, or transformative-technology startups with early validation and readiness to scale.", + "eligibility": "Applicants must be incorporated in Canada, build innovative and market-differentiated technology in MaRS focus areas, show early customer interest or validation, have an MVP or scientifically validated feasible technology, and be committed full-time founders ready to scale.", + "pricing": "free", + "pricing_note": "MaRS membership has no published fee; members are expected to share feedback and progress updates.", + "link_type": "dofollow", + "domain_rating": 75, + "monthly_traffic": 22800, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "mashable", + "name": "Mashable", + "type": "citation", + "website_url": "https://mashable.com", + "submission_url": "https://mashable.com/about/contact-us", + "logo_url": "https://www.google.com/s2/favicons?domain=mashable.com&sz=64", + "description": "Mashable is a digital media publication covering technology, digital culture, social media, and consumer technology.", + "audience": "Companies with timely, newsworthy technology or digital-culture stories; not a self-serve listing channel.", + "eligibility": "Editorial coverage is discretionary and requires a credible, relevant news angle rather than a routine product announcement.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 91, + "monthly_traffic": 7500000, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "mashable-india", + "name": "Mashable India", + "type": "citation", + "website_url": "https://in.mashable.com", + "submission_url": "https://mashable.com/about/contact-us", + "logo_url": "https://www.google.com/s2/favicons?domain=in.mashable.com&sz=64", + "description": "Mashable India is Mashable's India edition covering technology, internet culture, entertainment, and related news.", + "audience": "Companies with a genuinely newsworthy technology or digital-culture story relevant to Mashable India's editorial coverage.", + "eligibility": "Editorial coverage is discretionary and requires a credible, relevant news angle; no separate India-specific public submission form is available.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 91, + "monthly_traffic": 126500, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "maxibestof", + "name": "MaxiBestOf", + "type": "directory", + "website_url": "https://maxibestof.one/", + "submission_url": "https://maxibestof.one/submit", + "logo_url": "https://maxibestof.one/favicon.png", + "description": "Curated web-design inspiration directory featuring screenshots, typography, color palettes, industries, styles, and technology details from submitted websites.", + "audience": "Designers, developers, studios, and startup teams seeking recognition and referral exposure for polished public websites among people researching web-design patterns.", + "eligibility": "Create an account and submit a public website you created or worked on. MaxiBestOf personally reviews submissions and asks applicants not to resubmit the same site repeatedly.", + "pricing": "free", + "pricing_note": "Standard website submission is free according to the live submission page.", + "link_type": "unknown", + "domain_rating": 52, + "monthly_traffic": 11500, + "categories": [ + "design" + ] + }, + { + "slug": "medium", + "name": "Medium", + "type": "citation", + "website_url": "https://medium.com", + "submission_url": "https://medium.com/new-story", + "logo_url": "https://www.google.com/s2/favicons?domain=medium.com&sz=64", + "description": "Online publishing platform for people and companies to publish long-form articles, stories, and ideas.", + "audience": "Founders and teams that can publish useful original, policy-compliant writing; Medium is not a product-listing directory.", + "eligibility": "Anyone can publish with an account subject to Medium rules, while publications maintain their own criteria.", + "pricing": "free", + "pricing_note": "Free to publish with an account; Medium memberships and other paid features are optional.", + "link_type": "nofollow", + "domain_rating": 94, + "monthly_traffic": 24700000, + "categories": [ + "business", + "marketing" + ] + }, + { + "slug": "micro-saas-examples", + "name": "Micro SaaS Examples", + "type": "directory", + "website_url": "https://www.microsaasexamples.com/", + "submission_url": "https://www.microsaasexamples.com/submit", + "logo_url": "https://www.microsaasexamples.com/apple-touch-icon.png", + "description": "Curated directory of profitable micro-SaaS products and ideas, organized by category, audience, business model, and adjacent opportunities.", + "audience": "Micro-SaaS founders seeking a permanent product listing and discovery among entrepreneurs researching small, focused software businesses and ideas.", + "eligibility": "Submit a useful operating micro-SaaS product with accurate product details and assets. Use the featured or sponsored route if you do not want to place a permanent backlink on your own site.", + "pricing": "paid", + "pricing_note": "The $69 featured tier provides a permanent listing and one week of featured placement with no badge or backlink required. The $3 basic tier requires a permanent backlink and is not recommended here.", + "link_type": "unknown", + "domain_rating": 49, + "monthly_traffic": 1800, + "categories": [ + "saas", + "startup" + ] + }, + { + "slug": "microsaas", + "name": "r/microsaas", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/microsaas/", + "submission_url": "https://www.reddit.com/r/microsaas/submit", + "logo_url": "https://styles.redditmedia.com/t5_3n8ao/styles/communityIcon_jffqvvq8o2i91.png?width=256&s=2d8f4fc5cf30383b025c22b077bf5d6c985dc952", + "description": "A place to change your life with micro SaaS apps", + "audience": "Micro SaaS builders and founders sharing products, context, and lessons.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "indie", + "saas", + "startup" + ], + "subreddit": { + "members_count": 212254, + "growth_30d_absolute": 6083, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/microsaas/about/rules" + } + }, + { + "slug": "microsoft-marketplace", + "name": "Microsoft Marketplace", + "type": "marketplace", + "website_url": "https://marketplace.microsoft.com/", + "submission_url": "https://learn.microsoft.com/en-us/partner-center/marketplace-offers/submit-to-appsource-via-partner-center", + "logo_url": "https://www.google.com/s2/favicons?domain=marketplace.microsoft.com&sz=64", + "description": "Microsoft's marketplace for SaaS, Microsoft 365, Teams, Copilot, Power BI, Dynamics, Azure, and other business software offers.", + "audience": "Software publishers with production solutions that integrate with Microsoft products or qualify for a supported Microsoft Marketplace offer type.", + "eligibility": "The publisher must join Partner Center, accept the applicable partner and publisher agreements, and submit a supported, production-ready offer that passes Microsoft certification.", + "pricing": "free", + "pricing_note": "Microsoft states there is no cost to publish an offer. Transactable sales are subject to a standard 3% store service fee.", + "link_type": "dofollow", + "domain_rating": 96, + "monthly_traffic": 375000, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "microsoft-store", + "name": "Microsoft Store", + "type": "marketplace", + "website_url": "https://apps.microsoft.com/", + "submission_url": "https://learn.microsoft.com/en-us/windows/apps/publish/partner-center/open-a-developer-account", + "logo_url": "https://www.google.com/s2/favicons?domain=apps.microsoft.com&sz=64", + "description": "Microsoft's consumer storefront for discovering and installing Windows applications, games, and other supported digital products.", + "audience": "Developers distributing production-ready Windows applications or games to Microsoft Store users.", + "eligibility": "Create and verify an individual or company developer account in Partner Center, reserve the product identity, and submit a supported package that passes certification.", + "pricing": "free", + "pricing_note": "Microsoft currently lists individual and company Windows developer registration at no charge. Store transactions may have separate revenue-share terms.", + "link_type": "unknown", + "domain_rating": 96, + "monthly_traffic": 18400000, + "categories": [ + "developer-tools", + "productivity" + ] + }, + { + "slug": "mit-technology-review", + "name": "MIT Technology Review", + "type": "citation", + "website_url": "https://www.technologyreview.com", + "submission_url": "https://www.technologyreview.com/how-to-pitch-mit-technology-review/", + "logo_url": "https://www.google.com/s2/favicons?domain=technologyreview.com&sz=64", + "description": "MIT Technology Review publishes reporting and analysis about technology and its real-world impact.", + "audience": "Writers with timely, deeply reported technology stories, analysis, or features that serve an informed global audience.", + "eligibility": "Pitches must be original, timely, and relevant to technology's real-world impact; first-time contributors should include an introduction and relevant clips.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 90, + "monthly_traffic": 73500, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "mobileappdevelopers", + "name": "r/MobileAppDevelopers", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/MobileAppDevelopers/", + "submission_url": "https://www.reddit.com/r/MobileAppDevelopers/submit", + "logo_url": "https://styles.redditmedia.com/t5_3ornm/styles/communityIcon_brtcmmodlndf1.png?width=256&s=95d0afeae9630dab80b952ac29ed4a1711f8e50c", + "description": "Welcome to r/MobileAppDevelopers – a community for developers, designers, and entrepreneurs passionate about building and growing mobile apps. Whether you're working on Android, iOS, cross-platform tools, or UI/UX – this is the place to share ideas, get feedback, ask questions, and showcase your work.\n\nThis subreddit is moderated by the team at Digimonk Solution – a mobile and web development company helping businesses and startups build powerful digital products.\n\nLet’s keep this space helpful", + "audience": "Mobile app developers, designers, and entrepreneurs sharing projects and requesting feedback.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie", + "startup" + ], + "subreddit": { + "members_count": 16396, + "growth_30d_absolute": 102, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/MobileAppDevelopers/about/rules" + } + }, + { + "slug": "n8n-integrations", + "name": "n8n Integrations", + "type": "marketplace", + "website_url": "https://n8n.io/integrations", + "submission_url": "https://docs.n8n.io/integrations/creating-nodes/deploy/submit-community-nodes/", + "logo_url": "https://www.google.com/s2/favicons?domain=n8n.io&sz=64", + "description": "n8n is an automation platform whose integrations library includes verified community nodes and workflow templates.", + "audience": "Developers who can build compliant community nodes or original workflow templates for n8n users.", + "eligibility": "Community nodes must meet n8n's technical and packaging standards, avoid competing with paid features, and be published through the documented creator process.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 90, + "monthly_traffic": 2000000, + "categories": [ + "developer-tools", + "productivity" + ] + }, + { + "slug": "nextdoor", + "name": "Nextdoor", + "type": "directory", + "website_url": "https://nextdoor.com", + "submission_url": "https://business.nextdoor.com/en-us/getting-started/business-page", + "logo_url": "https://www.google.com/s2/favicons?domain=nextdoor.com&sz=64", + "description": "Nextdoor is a neighborhood social network where local businesses can claim or create Business Pages for discovery, recommendations, posts, and messaging.", + "audience": "Local businesses and service providers that serve a neighborhood and can truthfully represent an operating local presence.", + "eligibility": "The applicant must be an owner, employee, or authorized representative of a local business or service provider and may need to complete verification.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 91, + "monthly_traffic": 1400000, + "categories": [ + "business" + ] + }, + { + "slug": "nextjs", + "name": "r/nextjs", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/nextjs/", + "submission_url": "https://www.reddit.com/r/nextjs/submit", + "logo_url": "https://styles.redditmedia.com/t5_3h7yi/styles/communityIcon_nsrozhr9igl91.png?width=256&s=f48c940b5231ceb6cb810b8bb0bcf503395839bc", + "description": "Next.js is a React framework for building full-stack web applications", + "audience": "Next.js developers sharing framework-specific projects.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 174234, + "growth_30d_absolute": 1864, + "promotion_mode": "promotion_thread", + "rules_url": "https://www.reddit.com/r/nextjs/about/rules" + } + }, + { + "slug": "nocode", + "name": "r/nocode", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/nocode/", + "submission_url": "https://www.reddit.com/r/nocode/submit", + "logo_url": "https://styles.redditmedia.com/t5_3gbip/styles/communityIcon_99qld76bwkle1.png?width=256&s=80768f9b715d43615f84fd130ba4d194fb162e98", + "description": "No-Code Community dedicated to building cool things without needing to be a developer.", + "audience": "No-code builders, companies, and innovators sharing tools, products, and launches.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "productivity", + "startup" + ], + "subreddit": { + "members_count": 137416, + "growth_30d_absolute": 3294, + "promotion_mode": "direct_post+promotion_thread", + "rules_url": "https://www.reddit.com/r/nocode/about/rules" + } + }, + { + "slug": "nocode-tech", + "name": "Nocode.tech", + "type": "citation", + "website_url": "https://www.nocode.tech/", + "submission_url": "https://www.nocode.tech/contact", + "logo_url": "https://www.google.com/s2/favicons?domain=nocode.tech&sz=64", + "description": "Nocode.tech is a resource and curated directory for no-code tools, guides, courses, and a tool-matching quiz for building business software without code.", + "audience": "No-code and AI tools with a credible, useful fit for the site's curated tools hub and no-code audience.", + "eligibility": "The site publishes no formal listing criteria; suggestions must be relevant no-code or AI tools suitable for the curated tools hub and are subject to editorial judgment.", + "pricing": "free", + "pricing_note": "No fee is published for contacting the site or suggesting a relevant tool; any editorial inclusion is discretionary.", + "link_type": "dofollow", + "domain_rating": 65, + "monthly_traffic": 10100, + "categories": [ + "ai", + "indie" + ] + }, + { + "slug": "notion-marketplace", + "name": "Notion Marketplace", + "type": "marketplace", + "website_url": "https://www.notion.com/", + "submission_url": "https://app.notion.com/profile/connections", + "logo_url": "https://www.google.com/s2/favicons?domain=notion.so&sz=64", + "description": "Notion is a collaborative workspace for notes, documents, databases, wikis, and project management that operates marketplaces for integrations and templates.", + "audience": "Developers with public Notion integrations and creators with useful Notion templates.", + "eligibility": "Integration listings need a public connection with an Any workspace installation scope and must meet Notion quality, brand, and best-practice criteria; internal-only and selected-workspace connections are ineligible.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 89, + "monthly_traffic": 5200000, + "categories": [ + "developer-tools", + "productivity" + ] + }, + { + "slug": "notioncreations", + "name": "r/notioncreations", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/notioncreations/", + "submission_url": "https://www.reddit.com/r/notioncreations/submit", + "logo_url": "https://styles.redditmedia.com/t5_6sssdf/styles/communityIcon_2m1bepvyrr2a1.png?width=256&s=93d24abb0730fe917dbb87033bccb2d378d54794", + "description": "A subreddit to share your Notion templates, dashboards, and creations. \n Please add a link to your template. Posts that go along the lines of \"Reply [this] in the comments and I'll DM you the link 👋🏻\" are absolutely prohibited.", + "audience": "Notion users sharing templates, dashboards, and other Notion creations.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "community", + "productivity" + ], + "subreddit": { + "members_count": 23694, + "growth_30d_absolute": 835, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/notioncreations/about/rules" + } + }, + { + "slug": "oecd-ai", + "name": "OECD.AI", + "type": "citation", + "website_url": "https://oecd.ai/", + "submission_url": "https://oecd.ai/en/catalogue/tools/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=oecd.ai&sz=64", + "description": "OECD.AI is the Organisation for Economic Co-operation and Development's AI policy and resources platform, including a Catalogue of Tools and Metrics for Trustworthy AI.", + "audience": "Organisations and AI practitioners with tools, methods, or resources that help develop or use trustworthy AI systems aligned with the OECD AI Principles.", + "eligibility": "Submissions must support trustworthy AI, including fairness, transparency, explainability, robustness, security, safety, or human rights, and are vetted by the OECD Secretariat before publication.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 81, + "monthly_traffic": 34400, + "categories": [ + "ai", + "community" + ] + }, + { + "slug": "one-page-love", + "name": "One Page Love", + "type": "citation", + "website_url": "https://onepagelove.com/", + "submission_url": "https://onepagelove.com/submit/", + "logo_url": "https://www.google.com/s2/favicons?domain=onepagelove.com&sz=64", + "description": "One Page Love is a gallery and showcase of single-page websites, templates, and related design inspiration.", + "audience": "Designers, agencies, and product teams with a polished one-page website, landing page, portfolio, or qualifying template.", + "eligibility": "Submissions must be genuine one-page websites or qualifying one-page designs that meet One Page Love's editorial quality and format criteria; standard multi-page sites are not eligible.", + "pricing": "free", + "pricing_note": "Submitting is free; selected features may have optional paid listing, review, social, or newsletter promotion options.", + "link_type": "dofollow", + "domain_rating": 79, + "monthly_traffic": 40300, + "categories": [ + "design" + ] + }, + { + "slug": "open-collective", + "name": "Open Collective", + "type": "community", + "website_url": "https://opencollective.com/", + "submission_url": "https://opencollective.com/create", + "logo_url": "https://www.google.com/s2/favicons?domain=opencollective.com&sz=64", + "description": "Open Collective is a transparent funding platform where groups and projects, including open-source software, create Collectives to raise, manage, and spend money.", + "audience": "Open-source maintainers, software-project teams, and communities seeking transparent funding and expense management.", + "eligibility": "An Open Collective account is required; the Open Source Collective Fiscal Host path has a short verification, while other community Collectives have no verification step.", + "pricing": "free", + "pricing_note": "Creating a Collective is free; Fiscal Hosts typically charge 4–10% fees, and optional Platform Tips or organization plans may apply.", + "link_type": "nofollow", + "domain_rating": 89, + "monthly_traffic": 9800, + "categories": [ + "community", + "developer-tools" + ] + }, + { + "slug": "open-tools", + "name": "OpenTools", + "type": "directory", + "website_url": "https://opentools.ai/", + "submission_url": "https://opentools.ai/friends/launch-tool", + "logo_url": "https://www.google.com/s2/favicons?domain=opentools.ai&sz=64", + "description": "OpenTools is a discovery hub cataloging AI tools, models, MCP servers, experts, and resources for builders.", + "audience": "Builders and owners of AI tools seeking discovery through an AI-tools hub.", + "eligibility": "Listings are for novel AI tools and use cases that pass review; waitlist-only, affiliate, and spam listings are excluded.", + "pricing": "paid", + "pricing_note": "The launch package costs about US$199 per year and includes a homepage feature, social posts, and a listing; no free self-serve route was confirmed.", + "link_type": "dofollow", + "domain_rating": 69, + "monthly_traffic": 14100, + "categories": [ + "ai", + "developer-tools" + ] + }, + { + "slug": "openfuture-ai", + "name": "OpenFuture AI", + "type": "directory", + "website_url": "https://openfuture.ai/", + "submission_url": "https://openfuture.ai/submit-tool", + "logo_url": "https://www.google.com/s2/favicons?domain=openfuture.ai&sz=64", + "description": "OpenFuture AI is a large AI-tools aggregator and directory listing tens of thousands of tools across many categories with filters for discovery and comparison.", + "audience": "Makers of AI-related tools seeking discovery through a broad AI-tools directory.", + "eligibility": "The public submission page requires login and does not state detailed eligibility criteria; the directory's focus is AI-related tools.", + "pricing": "unknown", + "pricing_note": "The public submission page does not state a listing price.", + "link_type": "nofollow", + "domain_rating": 67, + "monthly_traffic": 420400, + "categories": [ + "ai" + ] + }, + { + "slug": "openhunts", + "name": "OpenHunts", + "type": "product-launch-site", + "website_url": "https://openhunts.com/", + "submission_url": "https://openhunts.com/projects/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=openhunts.com&sz=64", + "description": "OpenHunts is a product launch and discovery platform positioned as an open alternative to Product Hunt for makers and startups.", + "audience": "Indie makers and startup teams launching technology products, apps, and tools.", + "eligibility": "Eligible launches are technology products, apps, tools, and startups from indie makers and creators, from MVPs to established products.", + "pricing": "free", + "pricing_note": "Free Launch costs US$0 with limited weekly slots and a long queue; Premium Launch starts at about US$9.90, with higher highlight options available.", + "link_type": "dofollow", + "domain_rating": 70, + "monthly_traffic": 4900, + "categories": [ + "indie", + "startup" + ] + }, + { + "slug": "opensource", + "name": "r/opensource", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/opensource/", + "submission_url": "https://www.reddit.com/r/opensource/submit", + "logo_url": "https://styles.redditmedia.com/t5_2qh4n/styles/communityIcon_ac8a97n6u7t71.png?width=256&s=c824cfd51834ccdd9e01251d4abba4ab63d3628e", + "description": "A subreddit for everything open source related (for this context, we go off the definition of open source here http://en.wikipedia.org/wiki/Open_source)", + "audience": "Open-source developers and contributors sharing projects, news, and discussions.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "community", + "developer-tools" + ], + "subreddit": { + "members_count": 378323, + "growth_30d_absolute": 7384, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/opensource/about/rules" + } + }, + { + "slug": "pcmag", + "name": "PCMag", + "type": "citation", + "website_url": "https://www.pcmag.com", + "submission_url": "https://www.pcmag.com/about/contact-pcmag", + "logo_url": "https://www.google.com/s2/favicons?domain=pcmag.com&sz=64", + "description": "PCMag is a technology publication that provides independent, lab-tested product reviews, buying advice, and analysis.", + "audience": "Technology companies with a product that PCMag's editors independently judge suitable for testing or a genuinely newsworthy story.", + "eligibility": "PCMag independently selects products and stories for editorial coverage; it does not accept unsolicited content submissions or link requests.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 90, + "monthly_traffic": 6200000, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "peerlist", + "name": "Peerlist", + "type": "product-launch-site", + "website_url": "https://peerlist.io", + "submission_url": "https://peerlist.io/launchpad", + "logo_url": "https://peerlist.io/favicon_512.png", + "description": "Professional network for builders to showcase work, launch projects, and connect with other technology professionals.", + "audience": "Builders, designers, developers, and early-stage teams seeking feedback and early users for launched projects.", + "eligibility": "Projects seeking early users and feedback through Peerlist Launchpad.", + "pricing": "free", + "pricing_note": "Adding a project and Launchpad launch are free, while ads are paid.", + "link_type": "nofollow", + "domain_rating": 77, + "monthly_traffic": 85000, + "categories": [ + "community", + "startup" + ] + }, + { + "slug": "peerpush", + "name": "PeerPush", + "type": "product-launch-site", + "website_url": "https://peerpush.com/", + "submission_url": "https://peerpush.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=peerpush.net&sz=64", + "description": "PeerPush is a community-driven product-launch and discovery platform with structured product listings, rankings, and permanent listing pages.", + "audience": "Makers of software, SaaS, AI tools, and other products that can be described with structured use-case, audience, platform, and pricing information.", + "eligibility": "Products need clear structured details; pre-launch and waitlist products are eligible when the website clearly explains the product.", + "pricing": "free", + "pricing_note": "The free plan joins a publication queue; one-time paid plans offer immediate publication, permanent links, and optional promotion.", + "link_type": "dofollow", + "domain_rating": 73, + "monthly_traffic": 6000, + "categories": [ + "indie", + "startup" + ] + }, + { + "slug": "peerspot", + "name": "PeerSpot", + "type": "directory", + "website_url": "https://www.peerspot.com", + "submission_url": "https://company.peerspot.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=peerspot.com&sz=64", + "description": "Enterprise technology marketplace and customer-intelligence platform where vendors influence B2B buyers through validated customer evidence.", + "audience": "Enterprise technology vendors and business buyers researching enterprise software.", + "eligibility": "Enterprise B2B technology vendors only; this is not a general startup-submission destination.", + "pricing": "free", + "pricing_note": "Qualifying enterprise software may be listed without pay-to-play.", + "link_type": "nofollow", + "domain_rating": 74, + "monthly_traffic": 14200, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "pinterest", + "name": "Pinterest", + "type": "community", + "website_url": "https://www.pinterest.com", + "submission_url": "https://help.pinterest.com/en/business/article/get-a-business-account", + "logo_url": "https://www.google.com/s2/favicons?domain=pinterest.com&sz=64", + "description": "Pinterest is a visual discovery and social platform where users save and share ideas via Pins and boards.", + "audience": "Brands, creators, and marketers building organic visual discovery reach for products, content, and ideas.", + "eligibility": "Any business, brand, or creator can create a new business account or convert an existing personal account; no notability, traffic, or approval bar to clear.", + "pricing": "free", + "pricing_note": "Free to create or convert to a business account and build an organic presence; paid advertising is optional and separate.", + "link_type": "nofollow", + "domain_rating": 97, + "monthly_traffic": 550100000, + "categories": [ + "marketing", + "social" + ] + }, + { + "slug": "pitchbook", + "name": "PitchBook", + "type": "citation", + "website_url": "https://pitchbook.com/", + "submission_url": "https://pitchbook.com/request-your-pitchbook-profile", + "logo_url": "https://www.google.com/s2/favicons?domain=pitchbook.com&sz=64", + "description": "PitchBook is a private-markets intelligence platform covering companies, deals, investors, and related financial data.", + "audience": "Companies and investors with relevant private-market activity and reliable public data that PitchBook's research team may choose to cover.", + "eligibility": "Profile requests are reviewed by PitchBook Data Operations; coverage is not an automatic self-listing and depends on private-market relevance and available data.", + "pricing": "free", + "pricing_note": "Requesting or updating a profile is free, while full PitchBook platform access requires a paid subscription.", + "link_type": "dofollow", + "domain_rating": 84, + "monthly_traffic": 582100, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "pitchwall", + "name": "PitchWall", + "type": "product-launch-site", + "website_url": "https://pitchwall.co/", + "submission_url": "https://pitchwall.co/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=pitchwall.co&sz=64", + "description": "PitchWall showcases selected startups and digital technology products for discovery and visibility.", + "audience": "Makers of AI, SaaS, open-source, and other digital technology products, including eligible alpha and beta products.", + "eligibility": "Products need a clear landing page, clean UI and concise messaging, and pass manual review for legitimacy, activity, and relevance; agencies and non-product listings are excluded.", + "pricing": "free", + "pricing_note": "Free Launch has a wait of 30 days or more; Pro Launch costs US$49 and Premium Launch costs US$99 for faster or featured placement.", + "link_type": "dofollow", + "domain_rating": 68, + "monthly_traffic": 15200, + "categories": [ + "ai", + "developer-tools", + "saas", + "startup" + ] + }, + { + "slug": "pocket-lint", + "name": "Pocket-lint", + "type": "citation", + "website_url": "https://www.pocket-lint.com/", + "submission_url": "https://www.pocket-lint.com/page/press-events/", + "logo_url": "https://www.google.com/s2/favicons?domain=pocket-lint.com&sz=64", + "description": "Pocket-lint is a consumer technology and entertainment publication covering gadgets, news, hands-on reviews, guides, and features.", + "audience": "Consumer-technology companies with a genuinely relevant gadget, device, smart-home, entertainment, or related product that merits editorial consideration.", + "eligibility": "Products and stories must fit Pocket-lint's consumer-technology focus and editorial standards; the team independently selects what to test or cover and does not guarantee coverage.", + "pricing": "free", + "pricing_note": "Editorial pitches and reviews are unpaid; Pocket-lint does not accept payment for reviews.", + "link_type": "nofollow", + "domain_rating": 79, + "monthly_traffic": 190400, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "powerusers-ai", + "name": "Powerusers AI", + "type": "directory", + "website_url": "https://powerusers.ai/", + "submission_url": "https://powerusers.ai/submit-ai-tool/", + "logo_url": "https://www.google.com/s2/favicons?domain=powerusers.ai&sz=64", + "description": "Powerusers AI is a curated directory of AI tools for business owners and entrepreneurs, with filters, personal lists, and related guides.", + "audience": "Teams with live, high-quality AI productivity tools for business owners and entrepreneurs.", + "eligibility": "Listings must be live, high-quality, unique AI productivity tools with a direct HTTPS URL; closed betas, waitlists, redirects, affiliate links, repetitive or poorly built tools, and explicit pornography are excluded.", + "pricing": "paid", + "pricing_note": "A permanent done-for-you listing costs $69 one-time through PayPal and includes a backlink, a 48-hour turnaround, and one revision.", + "link_type": "dofollow", + "domain_rating": 33, + "monthly_traffic": 2300, + "categories": [ + "ai", + "productivity" + ] + }, + { + "slug": "product-hunt", + "name": "Product Hunt", + "type": "product-launch-site", + "website_url": "https://www.producthunt.com", + "submission_url": "https://www.producthunt.com/launch", + "logo_url": "https://www.producthunt.com/favicon.ico", + "description": "Daily launch platform where makers debut new products and reach early adopters, press, and investors.", + "audience": "Founders, makers, and early adopters browsing daily product launches", + "eligibility": "New or meaningfully updated products with a public product page.", + "pricing": "free", + "pricing_note": "Launching is free; paid visibility boosts exist.", + "link_type": "nofollow", + "domain_rating": 91, + "monthly_traffic": 253100, + "categories": [ + "indie", + "saas", + "startup" + ] + }, + { + "slug": "product-school-community", + "name": "Product School Community", + "type": "community", + "website_url": "https://productschool.com/slack-community/", + "submission_url": "https://productschool.com/slack-community/", + "logo_url": "https://www.google.com/s2/favicons?domain=productschool.com&sz=64", + "description": "Product School's Slack community for product managers to network, discuss product work, find answers, share knowledge, and discover industry opportunities.", + "audience": "Product managers, aspiring PMs, product leaders, and practitioners seeking peer discussion, career connections, events, resources, and professional visibility.", + "eligibility": "Join through the public community form with accurate contact details and use the Slack workspace for relevant product-management discussion and professional participation.", + "pricing": "free", + "pricing_note": "The Product School Slack community is presented as free to join through the live community form.", + "link_type": "unknown", + "domain_rating": 74, + "monthly_traffic": 55400, + "categories": [ + "community" + ] + }, + { + "slug": "producthunters", + "name": "r/ProductHunters", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/ProductHunters/", + "submission_url": "https://www.reddit.com/r/ProductHunters/submit", + "logo_url": "https://styles.redditmedia.com/t5_3ego4/styles/communityIcon_i2s2g09gitv61.jpg?width=256&s=093ecb5ee6efcdc48d4575451d23fd389f77632a", + "description": "Discussions about products on Product Hunt", + "audience": "Makers and Product Hunt community members discussing and sharing Product Hunt launches.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "indie", + "startup" + ], + "subreddit": { + "members_count": 59875, + "growth_30d_absolute": 3381, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/ProductHunters/about/rules" + } + }, + { + "slug": "promotemyapp", + "name": "r/promoteMyApp", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/promoteMyApp/", + "submission_url": "https://www.reddit.com/r/promoteMyApp/submit", + "logo_url": "https://styles.redditmedia.com/t5_3dssy2/styles/communityIcon_42foec956zx71.png?width=256&s=3bbed698eca451db4980f2af867e7a768355b643", + "description": "This is an Open community where you can freely promote your SaaS, Website, and mobile apps. We believe every builder should have a fair chance to promote their craft.", + "audience": "Builders promoting SaaS, websites, and mobile apps.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "marketing", + "saas" + ], + "subreddit": { + "members_count": 9299, + "growth_30d_absolute": 2478, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/promoteMyApp/about/rules" + } + }, + { + "slug": "proven-expert", + "name": "ProvenExpert", + "type": "directory", + "website_url": "https://www.provenexpert.com/en-us/", + "submission_url": "https://www.provenexpert.com/en-us/register/", + "logo_url": "https://www.google.com/s2/favicons?domain=provenexpert.com&sz=64", + "description": "ProvenExpert is a review and reputation platform where businesses and experts create profiles, collect customer reviews, and gain visibility through public listings.", + "audience": "Businesses, service providers, and experts who can maintain a public profile and collect authentic customer feedback.", + "eligibility": "Any business or service provider can register, but a public profile is required for visibility and ExpertCompass is limited to Germany-based companies.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 91, + "monthly_traffic": 61500, + "categories": [ + "business" + ] + }, + { + "slug": "psychology-today", + "name": "Psychology Today", + "type": "directory", + "website_url": "https://www.psychologytoday.com/us", + "submission_url": "https://member.psychologytoday.com/us/registration", + "logo_url": "https://www.google.com/s2/favicons?domain=psychologytoday.com&sz=64", + "description": "Mental-health publication and professional directory where people find therapists, psychiatrists, treatment centers, and support groups by location and specialty.", + "audience": "People seeking mental-health care and verified professionals or organizations offering therapy, psychiatric, treatment-center, or support-group services in the United States.", + "eligibility": "Directory profiles are for qualifying mental-health professionals and service organizations whose credentials and practice information can be verified; a general digital product without an eligible provider or service is not eligible.", + "pricing": "paid", + "pricing_note": "US membership is $29.95 per month with no plan or contract, according to the live registration page.", + "link_type": "unknown", + "domain_rating": 93, + "monthly_traffic": 6000000, + "categories": [ + "business" + ] + }, + { + "slug": "publicapis-dev", + "name": "PublicAPIs.dev", + "type": "directory", + "website_url": "https://publicapis.dev/", + "submission_url": "https://github.com/marcelscruz/public-apis/blob/main/CONTRIBUTING.md", + "logo_url": "https://publicapis.dev/favicon-pa/apple-touch-icon.png", + "description": "Collaborative directory of publicly documented, self-serve APIs organized by category with authentication and CORS details for developers.", + "audience": "API product teams seeking discovery among developers researching connectable public APIs for applications, automations, integrations, and experiments.", + "eligibility": "The API must be live, publicly documented, self-serve, reachable on a custom domain over HTTPS, and usable without a waitlist, closed beta, partner approval, or contact-sales gate. Low-effort projects and non-API tools are excluded.", + "pricing": "free", + "pricing_note": "Submitting a qualifying API through the public GitHub pull-request workflow has no listing fee. Free, freemium, and paid APIs are eligible when access is self-serve.", + "link_type": "unknown", + "domain_rating": 45, + "monthly_traffic": 4400, + "categories": [ + "developer-tools" + ] + }, + { + "slug": "python", + "name": "r/Python", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/Python/", + "submission_url": "https://www.reddit.com/r/Python/submit", + "logo_url": "https://styles.redditmedia.com/t5_2qh0y/styles/communityIcon_lctik80p96tg1.png?width=256&s=6d91fcc92d9f08c5ba30096d43a555ddd0edbc7d", + "description": "The largest Python community for Reddit! Stay up to date with the latest news, packages, and meta information relating to the Python programming language. \n---\n\nIf you have questions or are new to Python use r/LearnPython", + "audience": "Python developers publishing described, source-linked projects.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 1509287, + "growth_30d_absolute": 7383, + "promotion_mode": "promotion_thread", + "rules_url": "https://www.reddit.com/r/Python/about/rules" + } + }, + { + "slug": "quora", + "name": "Quora", + "type": "community", + "website_url": "https://www.quora.com", + "submission_url": "https://www.quora.com/business/create", + "logo_url": "https://www.google.com/s2/favicons?domain=quora.com&sz=64", + "description": "Quora is a question-and-answer platform where users discuss topics and businesses can create official profiles to participate under their brand name.", + "audience": "Legitimate businesses that can create an official brand profile and contribute useful, relevant answers to Quora discussions.", + "eligibility": "The profile creator needs a Quora account, a verified email on the business domain, a website, logo, and description; Quora reviews requests to prevent impersonation.", + "pricing": "free", + "pricing_note": "Creating a Business Profile and participating organically is free. Advertising is optional and separately priced.", + "link_type": "nofollow", + "domain_rating": 92, + "monthly_traffic": 78300000, + "categories": [ + "community", + "marketing" + ] + }, + { + "slug": "r-saas", + "name": "r/SaaS", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/SaaS/", + "submission_url": "https://www.reddit.com/r/SaaS/submit", + "logo_url": "https://styles.redditmedia.com/t5_2qkq6/styles/communityIcon_u7ddkuay2xn21.jpg?width=256&s=9054f6d63f23825552de4bd6f31328716775e412", + "description": "Discussions and useful links for SaaS owners, online business owners, and more.", + "audience": "SaaS owners and online business owners discussing the SaaS ecosystem, technology companies, and the business of running them.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "business", + "saas" + ], + "subreddit": { + "members_count": 791376, + "growth_30d_absolute": 27772, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/SaaS/about/rules" + } + }, + { + "slug": "raindrop", + "name": "Raindrop.io", + "type": "citation", + "website_url": "https://raindrop.io", + "submission_url": "https://help.raindrop.io/public-page", + "logo_url": "https://www.google.com/s2/favicons?domain=raindrop.io&sz=64", + "description": "Raindrop.io is a bookmark manager for organizing, sharing, and publishing collections of web links.", + "audience": "Founders who want to publish a genuinely useful, themed public resource collection that includes their website for a relevant audience.", + "eligibility": "Any user can create a collection, add their website as a bookmark, and publish that collection to a public Raindrop page; this is a user-owned backlink route, not an official product-directory listing.", + "pricing": "free", + "pricing_note": "Public collections and sharing are available on the Free plan.", + "link_type": "unknown", + "domain_rating": 76, + "monthly_traffic": 8800, + "categories": [ + "community", + "productivity" + ] + }, + { + "slug": "reactjs", + "name": "r/reactjs", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/reactjs/", + "submission_url": "https://www.reddit.com/r/reactjs/submit", + "logo_url": "https://styles.redditmedia.com/t5_2zldd/styles/communityIcon_fbblpo38vy941.png?width=256&s=13a87a036836ce95570a76feb53f27e61717ad1b", + "description": "A community for discussing anything related to the React UI framework and its ecosystem. Join the Reactiflux Discord (reactiflux.com) for additional React discussion and help.", + "audience": "React developers contributing useful answers and relevant external resources.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 512717, + "growth_30d_absolute": 2393, + "promotion_mode": "comments_only", + "rules_url": "https://www.reddit.com/r/reactjs/about/rules" + } + }, + { + "slug": "saas-ai-tools", + "name": "Saas AI Tools", + "type": "directory", + "website_url": "https://saasaitools.com/", + "submission_url": "https://saasaitools.com/submit-listing-2/", + "logo_url": "https://www.google.com/s2/favicons?domain=saasaitools.com&sz=64", + "description": "Saas AI Tools is a directory of generative AI and SaaS tools with daily AI news for founders and builders.", + "audience": "Makers of AI and SaaS tools seeking reviewed directory discovery.", + "eligibility": "Submissions must be AI or SaaS tools; affiliate links, newsletters, directories, automated submissions, and spam are not accepted, and staff review is required.", + "pricing": "free", + "pricing_note": "Free submissions use a review queue; an optional featured or fast-track option costs about $67 for priority.", + "link_type": "dofollow", + "domain_rating": 53, + "monthly_traffic": 3500, + "categories": [ + "ai", + "saas" + ] + }, + { + "slug": "saas-landing-page", + "name": "SaaS Landing Page", + "type": "directory", + "website_url": "https://saaslandingpage.com/", + "submission_url": "https://slpads.framer.website/contact", + "logo_url": "https://saaslandingpage.com/wp-content/themes/saaslandingpage/dist/images/apple-touch-icon.png", + "description": "Curated gallery of SaaS landing pages, product pages, pricing pages, onboarding screens, and other interface examples for design inspiration.", + "audience": "SaaS products seeking visibility among designers, founders, and product teams researching polished landing-page and interface examples.", + "eligibility": "Submit an operating product with a public product URL and visuals suitable for SaaS Landing Page's curated design audience. Placement is reviewed after inquiry and is not guaranteed.", + "pricing": "paid", + "pricing_note": "A single homepage product feature costs $550. A blog feature costs $500, while recurring sponsorship is advertised at $500 per month.", + "link_type": "unknown", + "domain_rating": 53, + "monthly_traffic": 29800, + "categories": [ + "design", + "saas" + ] + }, + { + "slug": "saasgenius", + "name": "SaaSGenius", + "type": "citation", + "website_url": "https://www.saasgenius.com/", + "submission_url": "https://www.saasgenius.com/get-listed/", + "logo_url": "https://www.google.com/s2/favicons?domain=saasgenius.com&sz=64", + "description": "SaaSGenius is a B2B software comparison site that adds products after an emailed request and payment.", + "audience": "Established B2B software companies with desktop-accessible products and US customers.", + "eligibility": "Products cannot be mobile-only, B2C-only, without US customers, or still in alpha or beta.", + "pricing": "paid", + "pricing_note": "The site is not accepting free listings and quotes an affordable listing fee on request.", + "link_type": "nofollow", + "domain_rating": 58, + "monthly_traffic": 6500, + "categories": [ + "business", + "saas", + "startup" + ] + }, + { + "slug": "saashub", + "name": "SaaSHub", + "type": "directory", + "website_url": "https://www.saashub.com", + "submission_url": "https://www.saashub.com/services/submit", + "logo_url": "https://cdn-b.saashub.com/assets/apple-touch-icon-0c442984b6603bd0c9aac1cff5747ca758ed3b7b14fd1b7a035cf370353f01f2.png", + "description": "Independent software marketplace for finding the best software and alternatives, helping software professionals since 2014.", + "audience": "Software professionals researching services and alternatives to grow their business", + "eligibility": "SaaS, IaaS & PaaS products, software and apps in English on their own domain; released products only — no agencies, waiting-list landing pages, or free hosting subdomains.", + "pricing": "free", + "pricing_note": "Submitting a product is free and goes through an approval queue; an optional paid \"Feature My Product\" promotion is offered.", + "link_type": "nofollow", + "domain_rating": 80, + "monthly_traffic": 5200, + "categories": [ + "developer-tools", + "saas" + ] + }, + { + "slug": "saasmarketing", + "name": "r/SaaSMarketing", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/SaaSMarketing/", + "submission_url": "https://www.reddit.com/r/SaaSMarketing/submit", + "logo_url": "https://styles.redditmedia.com/t5_4we32r/styles/communityIcon_6u4zku85oah71.png?width=256&s=3ffa5b9ae4d86976fb67766414c3771bbbf9b694", + "description": "A community dedicated to sharing and discussing all aspects of SaaS marketing, from super tactical stuff all the way up to overarching strategy. \n\nIf you're a SaaS founder and you're making $5K-$500K MRR, consider joining our premium mastermind community StartupSauce.com as well.", + "audience": "SaaS founders and marketers sharing useful SaaS marketing knowledge.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "marketing", + "saas" + ], + "subreddit": { + "members_count": 34806, + "growth_30d_absolute": 1699, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/SaaSMarketing/about/rules" + } + }, + { + "slug": "saaspirate", + "name": "SaaSPirate", + "type": "directory", + "website_url": "https://saaspirate.com/", + "submission_url": "https://saaspirate.com/submit-saas/", + "logo_url": "https://www.google.com/s2/favicons?domain=saaspirate.com&sz=64", + "description": "SaaSPirate is a platform that curates and lists SaaS lifetime deals, discounts, and software for entrepreneurs and businesses.", + "audience": "Makers of SaaS and software products seeking a curated deal or directory listing.", + "eligibility": "Eligible submissions are SaaS or software products with complete product information, clear pricing, quality images, support documentation, feature details, target users, and technical details; listings are verified.", + "pricing": "unknown", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 68, + "monthly_traffic": 1900, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "saaspo", + "name": "SaaSpo", + "type": "directory", + "website_url": "https://saaspo.com/", + "submission_url": "https://saaspo.com/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=saaspo.com&sz=64", + "description": "Curated collection of SaaS landing pages, interface sections, and web-design examples organized for visual product-design research.", + "audience": "SaaS teams with polished web design seeking discovery among designers, developers, and founders researching interface and landing-page inspiration.", + "eligibility": "SaaS websites are the primary focus, although related products may be selected when their design is useful to the gallery's audience. Every submission is reviewed and featuring is not guaranteed.", + "pricing": "free", + "pricing_note": "The standard website submission form is free. Separate advertising placements are available, including a $99 template listing and a $149 site-wide or feed promotion.", + "link_type": "unknown", + "domain_rating": 57, + "monthly_traffic": 13000, + "categories": [ + "design", + "saas" + ] + }, + { + "slug": "saassolopreneurs", + "name": "r/SaaSSolopreneurs", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/SaaSSolopreneurs/", + "submission_url": "https://www.reddit.com/r/SaaSSolopreneurs/submit", + "logo_url": "https://styles.redditmedia.com/t5_d2z8ab/styles/communityIcon_f1pmcod2wr5e1.jpg?width=256&s=7349eb7dce5c6d7853507d88802f23b760334335", + "description": "If you are not a software developer, but have a problem that you think could be solved with software, post about it in SaaSSolopreneurs, and good tings will happen✨\n\nFor those creating a software product, scaling a Micro SaaS business, or exploring the solopreneur journey, this is your space to connect, share, and grow ✨\n\nHere, we discuss:\n▶️ Building and scaling Micro SaaS products\n▶️ Marketing and growth strategies\n▶️ Tools, tech stacks, and frameworks\n▶️ Challenges and lessons learned", + "audience": "Solo founders and builders working on Micro SaaS products.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "indie", + "saas", + "startup" + ], + "subreddit": { + "members_count": 13602, + "growth_30d_absolute": 1805, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/SaaSSolopreneurs/about/rules" + } + }, + { + "slug": "saasworthy", + "name": "SaaSworthy", + "type": "directory", + "website_url": "https://saasworthy.com", + "submission_url": "https://saasworthy.com", + "logo_url": "https://www.google.com/s2/favicons?domain=saasworthy.com&sz=64", + "description": "SaaSworthy is a software comparison and review platform that tracks software products across categories using an SW Score ranking system.", + "audience": "Business software buyers comparing options and software vendors seeking visibility and leads.", + "eligibility": "SaaS or business software products must be suitable for tracking and categorization.", + "pricing": "free", + "pricing_note": "Standard includes a basic profile and organic review capture, with higher paid plans.", + "link_type": "dofollow", + "domain_rating": 75, + "monthly_traffic": 19300, + "categories": [ + "business", + "saas", + "startup" + ] + }, + { + "slug": "selecthub", + "name": "SelectHub", + "type": "directory", + "website_url": "https://www.selecthub.com/", + "submission_url": "https://pmo.selecthub.com/claim-your-product/", + "logo_url": "https://www.google.com/s2/favicons?domain=selecthub.com&sz=64", + "description": "SelectHub is a software selection and analyst research platform with product directories, comparisons, requirements tools, and vendor-buyer matching for business and enterprise software.", + "audience": "Software vendors seeking product-directory visibility with buyers evaluating business and enterprise software.", + "eligibility": "Products must appear in SelectHub categories or leaderboards and complete the verification required to claim the product profile.", + "pricing": "free", + "pricing_note": "The basic claim and listing path is available with paid seller programs for analyst validation, leads, and enhanced profiles.", + "link_type": "dofollow", + "domain_rating": 74, + "monthly_traffic": 16700, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "selfhosted", + "name": "r/selfhosted", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/selfhosted/", + "submission_url": "https://www.reddit.com/r/selfhosted/submit", + "logo_url": "https://styles.redditmedia.com/t5_32hch/styles/communityIcon_zttnt3jjmddh1.png?width=256&s=e749227af904e8209c8842a36e2a1de9aba64a6d", + "description": "A place to share, discuss, discover, assist with, gain assistance for, and critique self-hosted alternatives to our favorite web apps, web services, and online tools.", + "audience": "People discovering, building, and getting help with self-hosted alternatives to web apps, services, and tools.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "community", + "developer-tools" + ], + "subreddit": { + "members_count": 828714, + "growth_30d_absolute": 21091, + "promotion_mode": "comments_only", + "rules_url": "https://www.reddit.com/r/selfhosted/about/rules" + } + }, + { + "slug": "seogrowth", + "name": "r/seogrowth", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/seogrowth/", + "submission_url": "https://www.reddit.com/r/seogrowth/submit", + "logo_url": "https://styles.redditmedia.com/t5_4t7j8c/styles/communityIcon_gkpdve958xd71.png?width=256&s=4fa7109771fae6804dfd2fd7e68f54726e89a3d6", + "description": "Subreddit dedicated to growing blogs, websites, and businesses via SEO. Home to the best tips, tricks, case studies and more!\n\nNot sure where to start? \n\nCheck the pinned post for a starter guide.", + "audience": "SEO practitioners and people growing blogs, websites, and businesses through SEO.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "community", + "marketing", + "startup" + ], + "subreddit": { + "members_count": 80309, + "growth_30d_absolute": 4346, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/seogrowth/about/rules" + } + }, + { + "slug": "serchen", + "name": "Serchen", + "type": "directory", + "website_url": "https://www.serchen.com/", + "submission_url": "https://www.serchen.com/get-listed/", + "logo_url": "https://www.google.com/s2/favicons?domain=serchen.com&sz=64", + "description": "Serchen is a business-software marketplace and review platform for discovering, comparing, and reviewing software applications.", + "audience": "Vendors of cloud services, IaaS, PaaS, SaaS, and business software applications.", + "eligibility": "Eligible listings are software vendors and business software products using the Get Listed or claim-profile route.", + "pricing": "free", + "pricing_note": "Basic Get Listed and profile claim are available without a required fee; premium features and advertising are separate.", + "link_type": "dofollow", + "domain_rating": 67, + "monthly_traffic": 11600, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "shopify-app-store", + "name": "Shopify App Store", + "type": "marketplace", + "website_url": "https://apps.shopify.com/", + "submission_url": "https://shopify.dev/docs/apps/launch/app-store-review/submit-app-for-review", + "logo_url": "https://www.google.com/s2/favicons?domain=apps.shopify.com&sz=64", + "description": "Shopify's marketplace for public applications that help merchants operate, market, customize, and grow their online stores.", + "audience": "App developers offering a stable, multi-merchant Shopify integration that solves a clear commerce or store-management problem.", + "eligibility": "The app must use an eligible public distribution method, comply with Shopify App Store requirements, be production ready, and pass automated and manual review.", + "pricing": "paid", + "pricing_note": "Shopify requires a one-time $19 USD App Store registration fee per Partner account. Shopify's current revenue-share and payment-processing terms apply to app earnings.", + "link_type": "dofollow", + "domain_rating": 96, + "monthly_traffic": 505200, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "show-me-local", + "name": "Show Me Local", + "type": "citation", + "website_url": "https://www.showmelocal.com/", + "submission_url": "https://www.showmelocal.com/start-submission.aspx", + "logo_url": "https://www.google.com/s2/favicons?domain=showmelocal.com&sz=64", + "description": "Show Me Local is an international local-business directory and growth platform offering listings, reviews, and digital tools for small and medium businesses.", + "audience": "Businesses with operated physical locations seeking a local-directory mention.", + "eligibility": "The business must have a physical location; pure online or virtual-only, adult, MLM, and restricted businesses are not accepted, and submissions are reviewed against the guidelines.", + "pricing": "free", + "pricing_note": "Basic listings are free; paid upgrades, subscriptions, optional verification, and advertising packages are available.", + "link_type": "nofollow", + "domain_rating": 78, + "monthly_traffic": 45300, + "categories": [ + "business", + "marketing" + ] + }, + { + "slug": "showyourapp", + "name": "r/ShowYourApp", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/ShowYourApp/", + "submission_url": "https://www.reddit.com/r/ShowYourApp/submit", + "logo_url": "https://styles.redditmedia.com/t5_fpvb23/styles/communityIcon_pfh7xyzbcnwf1.png?width=256&s=5de9c42bc4a5cb1167f2fac53114f18a7ceb2596", + "description": "Welcome to r/ShowYourApp 🚀- A community for indie makers, developers, and founders to share their apps, web apps, SaaS, or software projects.\n\n\n💡 Show what you’ve built\n🌍 Get feedback & exposure\n🤝 Connect with other creators\n\nWhether it’s your first launch or your 10th, this is your space to showcase your work and inspire others!", + "audience": "Indie makers, developers, and founders showcasing apps, SaaS products, and software projects.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie", + "startup" + ], + "subreddit": { + "members_count": 16398, + "growth_30d_absolute": 1674, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/ShowYourApp/about/rules" + } + }, + { + "slug": "sideprojectors", + "name": "SideProjectors", + "type": "marketplace", + "website_url": "https://www.sideprojectors.com", + "submission_url": "https://www.sideprojectors.com/submit/type", + "logo_url": "https://www.google.com/s2/favicons?domain=sideprojectors.com&sz=64", + "description": "Marketplace for buying, selling, promoting, and showcasing digital products and side projects.", + "audience": "Indie makers and buyers of digital products and side projects.", + "eligibility": "Digital products and side projects that can be sold, bought, promoted, or showcased on the marketplace.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 70, + "monthly_traffic": 5200, + "categories": [ + "indie", + "startup" + ] + }, + { + "slug": "sideprojects", + "name": "r/sideprojects", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/sideprojects/", + "submission_url": "https://www.reddit.com/r/sideprojects/submit", + "logo_url": "https://styles.redditmedia.com/t5_aom859/styles/communityIcon_5t4890uupb7f1.png?width=256&s=45f12ce0ea90a0bc5e1643e3da98a1621ecbb34e", + "description": "A community to share and discuss side projects.", + "audience": "Developers and makers sharing side projects and seeking constructive feedback.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 49883, + "growth_30d_absolute": 5487, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/sideprojects/about/rules" + } + }, + { + "slug": "siliconangle", + "name": "SiliconANGLE", + "type": "citation", + "website_url": "https://siliconangle.com/", + "submission_url": "https://siliconangle.com/about/", + "logo_url": "https://www.google.com/s2/favicons?domain=siliconangle.com&sz=64", + "description": "SiliconANGLE is an independent media company covering enterprise and emerging technology through news, analysis, interviews, and theCUBE video coverage.", + "audience": "Enterprise and emerging-technology companies with a genuinely newsworthy story, evidence, or expert perspective relevant to SiliconANGLE's coverage.", + "eligibility": "There is no public self-serve company listing; editorial coverage is for newsworthy enterprise technology stories, while guest contributions require relevant expertise.", + "pricing": "free", + "pricing_note": "Editorial coverage is free when selected; paid partnership and sponsorship options are separate from the editorial route.", + "link_type": "dofollow", + "domain_rating": 83, + "monthly_traffic": 15300, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "siteinspire", + "name": "SiteInspire", + "type": "citation", + "website_url": "https://www.siteinspire.com/", + "submission_url": "https://www.siteinspire.com/about", + "logo_url": "https://www.google.com/s2/favicons?domain=siteinspire.com&sz=64", + "description": "SiteInspire is a curated showcase of high-quality web design examples from around the world.", + "audience": "Designers, agencies, and teams with an innovative, visually strong website that may merit editorial consideration.", + "eligibility": "Featured sites are selected on merit for innovative, aesthetically pleasing design, creativity, and visual quality; paid sponsorship does not buy inclusion.", + "pricing": "free", + "pricing_note": "Editorial consideration is free; separate paid options exist for premium profiles, jobs, and advertising but not guaranteed features.", + "link_type": "dofollow", + "domain_rating": 76, + "monthly_traffic": 62200, + "categories": [ + "design" + ] + }, + { + "slug": "sitejabber", + "name": "Sitejabber", + "type": "directory", + "website_url": "https://www.smartcustomer.com/", + "submission_url": "https://www.smartcustomer.com/business", + "logo_url": "https://www.google.com/s2/favicons?domain=sitejabber.com&sz=64", + "description": "Sitejabber is an online review platform where consumers rate businesses and companies manage public profiles and feedback.", + "audience": "Authorized representatives of businesses with a public website who need to create or claim a public business profile.", + "eligibility": "A business must have a public website, and the account creator must be an authorized representative of that business.", + "pricing": "free", + "pricing_note": "Basic business-profile and limited review-management access are free; advanced review collection, monitoring, distribution, and automation features are paid.", + "link_type": "nofollow", + "domain_rating": 81, + "monthly_traffic": 9000, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "sitelike", + "name": "Sitelike.org", + "type": "directory", + "website_url": "https://www.sitelike.org/", + "submission_url": "https://www.sitelike.org/add-site", + "logo_url": "https://www.google.com/s2/favicons?domain=sitelike.org&sz=64", + "description": "Sitelike.org is a free tool for finding similar websites and alternatives based on keywords, topics, audience, and popularity.", + "audience": "Website owners seeking inclusion in a similar-sites and alternatives discovery index.", + "eligibility": "Eligible submissions are websites suitable for kids; adult, gambling, phishing, casino, and similar content is blocked, and suggestions must be clearly related to an existing similar-site list.", + "pricing": "free", + "pricing_note": "Adding or updating a site is free.", + "link_type": "dofollow", + "domain_rating": 66, + "monthly_traffic": 8800, + "categories": [ + "business" + ] + }, + { + "slug": "sitepoint-forums", + "name": "SitePoint Forums", + "type": "community", + "website_url": "https://www.sitepoint.com/community/", + "submission_url": "https://www.sitepoint.com/community/signup", + "logo_url": "https://www.google.com/s2/favicons?domain=sitepoint.com&sz=64", + "description": "Long-running web-development forum covering programming, HTML and CSS, JavaScript, WordPress, design, business, hosting, and general developer questions.", + "audience": "Web developers, designers, site owners, technical learners, and software professionals exchanging implementation advice and feedback.", + "eligibility": "Anyone may register, but posts must fit a forum category, add genuine discussion value, and avoid promotional spam or undisclosed AI-generated answers.", + "pricing": "free", + "pricing_note": "Forum registration and ordinary participation are free.", + "link_type": "dofollow", + "domain_rating": 86, + "monthly_traffic": 86100, + "categories": [ + "community", + "developer-tools" + ] + }, + { + "slug": "slack-app-directory", + "name": "Slack App Directory", + "type": "marketplace", + "website_url": "https://slack.com", + "submission_url": "https://api.slack.com/apps", + "logo_url": "https://www.google.com/s2/favicons?domain=slack.com&sz=64", + "description": "Slack is a channel-based collaboration platform with a third-party App Marketplace for integrations and workflow tools.", + "audience": "Developers of public, production-ready Slack apps that can meet Marketplace review and usage requirements.", + "eligibility": "Apps must be fully functional and publicly distributable, comply with Slack Marketplace policies and security requirements, and normally be installed on at least five active workspaces with at least ten weekly active users.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 92, + "monthly_traffic": 4500000, + "categories": [ + "developer-tools", + "productivity" + ] + }, + { + "slug": "slate", + "name": "Slate", + "type": "citation", + "website_url": "https://slate.com", + "submission_url": "https://slate.com/pitch", + "logo_url": "https://www.google.com/s2/favicons?domain=slate.com&sz=64", + "description": "Slate is an online magazine that commissions opinion, analysis, reporting, essays, and culture coverage.", + "audience": "Writers and experts with original, researched ideas that fit Slate's editorial voice and coverage areas.", + "eligibility": "Slate accepts freelance pitches that offer a surprising argument or original reporting, fit an existing section, and avoid duplicating recent coverage.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 90, + "monthly_traffic": 457800, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "smashing-magazine", + "name": "Smashing Magazine", + "type": "citation", + "website_url": "https://www.smashingmagazine.com", + "submission_url": "https://www.smashingmagazine.com/contact/", + "logo_url": "https://www.google.com/s2/favicons?domain=smashingmagazine.com&sz=64", + "description": "Smashing Magazine publishes practical articles, guides, case studies, and opinions for web designers and developers.", + "audience": "Experienced practitioners and new writers who can share original, practical, and well-researched web design or development knowledge.", + "eligibility": "Article proposals must be original, non-promotional, practical or well-researched, and useful to professional designers or developers.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 90, + "monthly_traffic": 43300, + "categories": [ + "community", + "developer-tools" + ] + }, + { + "slug": "softonic", + "name": "Softonic", + "type": "directory", + "website_url": "https://www.softonic.com", + "submission_url": "https://publishing-center.softonic.com/home", + "logo_url": "https://www.google.com/s2/favicons?domain=softonic.com&sz=64", + "description": "Softonic is a software discovery and distribution catalog where developers can publish or manage application listings.", + "audience": "Developers with eligible desktop, mobile, web, extension, utility, or SaaS applications that meet Softonic's publication policies.", + "eligibility": "Apps must fit Softonic's supported platforms and pass its human editorial and policy review before publication.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 86, + "monthly_traffic": 62100000, + "categories": [ + "developer-tools", + "saas" + ] + }, + { + "slug": "softpedia", + "name": "Softpedia", + "type": "directory", + "website_url": "https://www.softpedia.com", + "submission_url": "https://www.softpedia.com/user/submit.shtml", + "logo_url": "https://www.google.com/s2/favicons?domain=softpedia.com&sz=64", + "description": "Softpedia is a software download library where developers submit product listings for editorial selection and publication.", + "audience": "Software developers with eligible Windows, Mac, Linux, games, or utility products ready for public distribution.", + "eligibility": "Submissions must provide accurate software details and enter Softpedia's selection queue; publication is not guaranteed and existing listings should be checked first.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 86, + "monthly_traffic": 235100, + "categories": [ + "developer-tools", + "saas" + ] + }, + { + "slug": "software-advice", + "name": "Software Advice", + "type": "directory", + "website_url": "https://www.softwareadvice.com", + "submission_url": "https://app.g2digitalmarkets.com/get-listed/start", + "logo_url": "https://www.google.com/s2/favicons?domain=softwareadvice.com&sz=64", + "description": "B2B software discovery platform where businesses compare software and vendors can establish product listings for in-market buyers.", + "audience": "B2B software buyers comparing products and software vendors seeking qualified buyer discovery.", + "eligibility": "B2B software vendors seeking a product listing and buyer discovery through Software Advice/G2 Digital Markets.", + "pricing": "free", + "pricing_note": "Qualifying products can receive a free listing, with optional lead programs.", + "link_type": "nofollow", + "domain_rating": 87, + "monthly_traffic": 192400, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "softwaresuggest", + "name": "SoftwareSuggest", + "type": "directory", + "website_url": "https://www.softwaresuggest.com", + "submission_url": "https://www.softwaresuggest.com/vendors", + "logo_url": "https://www.softwaresuggest.com/apple-touch-icon.png", + "description": "Business software directory with 50k+ products, expert reviews, comparisons, and free recommendations for buyers.", + "audience": "Businesses researching and comparing software with expert reviews and recommendations", + "eligibility": "Business software products; vendors create and manage listings through the vendor portal.", + "pricing": "free", + "pricing_note": "Creating a listing is free; paid visibility and combo plans are offered to vendors.", + "link_type": "nofollow", + "domain_rating": 78, + "monthly_traffic": 112700, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "softwareworld", + "name": "SoftwareWorld", + "type": "directory", + "website_url": "https://www.softwareworld.co/", + "submission_url": "https://www.softwareworld.co/", + "logo_url": "https://www.google.com/s2/favicons?domain=softwareworld.co&sz=64", + "description": "SoftwareWorld is a review, ranking, and comparison platform that helps businesses discover and evaluate software products and service providers.", + "audience": "Licensed software vendors and companies with an authorized public website and a complete product or company profile.", + "eligibility": "Listings need the authorized site and complete profile details including logo, name, tagline, categories, pricing, features, languages, licensing, deployment, devices, social links, and description; approval typically takes 24–48 working hours.", + "pricing": "free", + "pricing_note": "Basic listings are free; paid Featured and Sponsored plans offer ranking, interviews, PR, and other promotion.", + "link_type": "dofollow", + "domain_rating": 73, + "monthly_traffic": 14800, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "sourceforge", + "name": "SourceForge", + "type": "community", + "website_url": "https://sourceforge.net", + "submission_url": "https://sourceforge.net/p/add_project", + "logo_url": "https://www.google.com/s2/favicons?domain=sourceforge.net&sz=64", + "description": "Open-source software forge and directory for hosting projects, distributing releases, and building project communities.", + "audience": "Open-source software maintainers and teams publishing downloadable projects and releases.", + "eligibility": "Open-source projects that can be hosted and distributed through SourceForge; this is not a general external-link SaaS directory.", + "pricing": "free", + "pricing_note": "SourceForge's create page describes free hosting, downloads, statistics, and global mirrors for open-source projects.", + "link_type": "dofollow", + "domain_rating": 92, + "monthly_traffic": 2800000, + "categories": [ + "developer-tools" + ] + }, + { + "slug": "spot-saas", + "name": "Spot SaaS", + "type": "marketplace", + "website_url": "https://www.spotsaas.com/", + "submission_url": "https://www.spotsaas.com/get-listed", + "logo_url": "https://www.google.com/s2/favicons?domain=spotsaas.com&sz=64", + "description": "Spot SaaS is a curated B2B software marketplace and comparison site where buyers research, compare, and shortlist business software products.", + "audience": "Vendors of live commercial B2B software products seeking marketplace discovery and buyer comparison visibility.", + "eligibility": "Eligible listings are active, maintained commercial B2B products that are available for purchase or trial, have a verified company and website, serve a genuine B2B use case, contain accurate complete information, and fit a defined software category.", + "pricing": "free", + "pricing_note": "Basic product listing is free with no credit card; paid Growth and Strategic plans add visibility, multi-category placement, demos, and lead tools at custom pricing.", + "link_type": "dofollow", + "domain_rating": 67, + "monthly_traffic": 25300, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "stack-overflow", + "name": "Stack Overflow", + "type": "citation", + "website_url": "https://stackoverflow.com", + "submission_url": "https://stackoverflow.com/help/promotion", + "logo_url": "https://www.google.com/s2/favicons?domain=stackoverflow.com&sz=64", + "description": "Stack Overflow is a question-and-answer community for programmers, used by millions of developers to solve technical problems.", + "audience": "Developer tools and SaaS products whose team can answer real technical questions well, where mentioning the product is a genuinely useful, non-spammy part of the answer.", + "eligibility": "No self-serve listing form. Mentioning your own product is only acceptable as an incidental, clearly-disclosed part of a genuinely helpful answer to a real question (Stack Overflow's self-promotion policy); answers that exist mainly to link out are removed as spam.", + "pricing": "free", + "pricing_note": "Free; there is no paid placement, and paying for mentions would violate the self-promotion policy.", + "link_type": "nofollow", + "domain_rating": 92, + "monthly_traffic": 6500000, + "categories": [ + "developer-tools" + ] + }, + { + "slug": "stackshare", + "name": "StackShare", + "type": "directory", + "website_url": "https://stackshare.io", + "submission_url": "https://stackshare.io", + "logo_url": "https://stackshare.io/favicon.ico", + "description": "Directory for discovering and comparing technology stacks, developer tools, and AI tools used by product teams.", + "audience": "Developers and product teams researching technology stacks and software tools.", + "eligibility": "Technology and AI tools that can be listed in StackShare’s tool directory.", + "pricing": "free", + "pricing_note": "Claiming or submitting a tool page is free, while promotion is paid.", + "link_type": "dofollow", + "domain_rating": 79, + "monthly_traffic": 8600, + "categories": [ + "ai", + "developer-tools" + ] + }, + { + "slug": "stacksocial", + "name": "StackSocial", + "type": "citation", + "website_url": "https://www.stacksocial.com/", + "submission_url": "https://www.stackcommerce.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=stacksocial.com&sz=64", + "description": "StackSocial is StackCommerce's consumer tech-deals storefront, where products appear through negotiated vendor partnerships.", + "audience": "Software, course, and gadget vendors able to offer merchandisable consumer deals through a negotiated partnership.", + "eligibility": "StackCommerce must approve a Vendor Agreement or Promotional Agreement and selects products it can sell on its network.", + "pricing": "unknown", + "pricing_note": "No public submission fee is published; commercial terms and optional Featured Promotions are negotiated.", + "link_type": "nofollow", + "domain_rating": 72, + "monthly_traffic": 59400, + "categories": [ + "business", + "marketing", + "saas" + ] + }, + { + "slug": "startup-blink", + "name": "StartupBlink", + "type": "directory", + "website_url": "https://www.startupblink.com/", + "submission_url": "https://www.startupblink.com/startups/add", + "logo_url": "https://www.google.com/s2/favicons?domain=startupblink.com&sz=64", + "description": "StartupBlink is a global startup-ecosystem research platform and interactive map that lists and ranks startups, ecosystems, and related entities for discovery, benchmarking, and reports.", + "audience": "Official startup representatives seeking ecosystem discovery, benchmarking, and inclusion in startup research and reports.", + "eligibility": "The submitter must be an official representative of the organization; an email-domain match is preferred and may otherwise require verification, and a 30- to 350-character description is required.", + "pricing": "free", + "pricing_note": "The submission form and related pages do not state a listing fee.", + "link_type": "nofollow", + "domain_rating": 72, + "monthly_traffic": 12600, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "startup-buffer", + "name": "Startup Buffer", + "type": "directory", + "website_url": "https://startupbuffer.com/", + "submission_url": "https://startupbuffer.com/site/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=startupbuffer.com&sz=64", + "description": "Startup Buffer is a public startup directory organized by market, location, tags, and related products, with founder-submitted profiles that go through review.", + "audience": "Startup founders seeking a reviewed public directory profile for their product.", + "eligibility": "Submit a reviewable startup profile with a name, website URL, business email, short pitch, full description, and screenshot; category, location, and tags are optional, and listing is not automatic.", + "pricing": "free", + "pricing_note": "A profile can be submitted for review without payment; separate paid promotion packages offer faster or tracked placement.", + "link_type": "dofollow", + "domain_rating": 43, + "monthly_traffic": 2000, + "categories": [ + "business", + "saas", + "startup" + ] + }, + { + "slug": "startup-grind", + "name": "Startup Grind", + "type": "community", + "website_url": "https://www.startupgrind.com/", + "submission_url": "https://startup.startupgrind.com/apply/", + "logo_url": "https://www.google.com/s2/favicons?domain=startupgrind.com&sz=64", + "description": "Global startup community operating local chapters, founder events, a vetted startup membership, investor introductions, and startup resources.", + "audience": "Early-stage technology startups seeking founder community, networking, mentoring, investor exposure, events, and partner benefits.", + "eligibility": "Tech-driven startups from MVP through Series A may apply. A working website is mandatory; agencies, consultancies, service businesses, and other non-tech-driven companies are not accepted.", + "pricing": "free", + "pricing_note": "The startup membership application and community access are presented as free; conference exhibition and sponsorship opportunities are separate paid programs.", + "link_type": "unknown", + "domain_rating": 76, + "monthly_traffic": 17000, + "categories": [ + "community", + "startup" + ] + }, + { + "slug": "startup-heroes", + "name": "Startup Heroes", + "type": "directory", + "website_url": "https://startupheroes.io/", + "submission_url": "https://startupheroes.io/submit/startup/", + "logo_url": "https://www.google.com/s2/favicons?domain=startupheroes.io&sz=64", + "description": "Manually reviewed startup directory with company profiles organized by industry, technology category, country, region, and founding year.", + "audience": "Startups seeking a persistent company profile and discovery among readers researching emerging companies, industries, and startup ecosystems.", + "eligibility": "Operating startups with accurate company, website, category, location, founding-year, founder, funding, and social details may submit. Every listing is manually reviewed against the site's community guidelines.", + "pricing": "paid", + "pricing_note": "A startup submission costs $10 USD. Listings do not expire, and the site states that rejected submissions are refunded.", + "link_type": "unknown", + "domain_rating": 14, + "monthly_traffic": 1100, + "categories": [ + "startup" + ] + }, + { + "slug": "startup-ranking", + "name": "Startup Ranking", + "type": "product-launch-site", + "website_url": "https://www.startupranking.com/", + "submission_url": "https://www.startupranking.com/startup/create", + "logo_url": "https://www.google.com/s2/favicons?domain=startupranking.com&sz=64", + "description": "Startup Ranking is a platform that discovers and ranks startups worldwide using an SR Score based on web importance and social influence, with country and state rankings.", + "audience": "Startup founders and companies worldwide seeking a public ranking profile and discovery by startup researchers and prospects.", + "eligibility": "Any startup or company worldwide may submit its details, validate its URL, and claim or verify ownership by HTML file, tweet, or domain email before review and ranking-data collection.", + "pricing": "free", + "pricing_note": "Startup registration and listing are free; a paid option of about $99 accelerates approval from the typical 60–80 days to 24 hours, with additional paid booster and advertising services available.", + "link_type": "dofollow", + "domain_rating": 62, + "monthly_traffic": 10800, + "categories": [ + "saas", + "startup" + ] + }, + { + "slug": "startup-stash", + "name": "Startup Stash", + "type": "directory", + "website_url": "https://startupstash.com", + "submission_url": "https://startupstash.com/add-listing/", + "logo_url": "https://startupstash.com/wp-content/uploads/2018/06/startupstash-icon-90x90-300x300.png", + "description": "One of the world's largest curated online directories of tools and resources for startups.", + "audience": "Startup founders looking for tools and resources to build and grow their company", + "eligibility": "Tools and resources useful to startups; listing requests are reviewed before publication.", + "pricing": "unknown", + "pricing_note": "Listing requests go through an embedded Typeform; pricing is not published on the site.", + "link_type": "dofollow", + "domain_rating": 65, + "monthly_traffic": 19900, + "categories": [ + "business", + "saas", + "startup" + ] + }, + { + "slug": "startupaccelerator", + "name": "r/startupaccelerator", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/startupaccelerator/", + "submission_url": "https://www.reddit.com/r/startupaccelerator/submit", + "logo_url": "https://styles.redditmedia.com/t5_fs1mi8/styles/communityIcon_i0gkelwub7yf1.png?width=256&s=bddc5529c9e29674e0bcebc78442ca9ee609083d", + "description": "Helping startups to accelerate - startupaccelerator.dev", + "audience": "Startup and SaaS founders sharing projects and asking for feedback.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "saas", + "startup" + ], + "subreddit": { + "members_count": 17598, + "growth_30d_absolute": 1361, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/startupaccelerator/about/rules" + } + }, + { + "slug": "startupdach", + "name": "r/StartupDACH", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/StartupDACH/", + "submission_url": "https://www.reddit.com/r/StartupDACH/submit", + "logo_url": "https://styles.redditmedia.com/t5_3fkh42/styles/communityIcon_77972or0uzaf1.png?width=256&s=9f4a8f68787cbe6ce4483ec316a148b03c2188f2", + "description": "Willkommen in der größten deutschsprachigen Startup-Community!\nHier treffen sich Gründer:innen, Investor:innen und alle, die sich für Startups in Deutschland, Österreich und der Schweiz interessieren.", + "audience": "Startup founders in the German-speaking DACH ecosystem sharing companies and products for professional exchange.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "business", + "startup" + ], + "subreddit": { + "members_count": 20295, + "growth_30d_absolute": 1441, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/StartupDACH/about/rules" + } + }, + { + "slug": "startups-eu", + "name": "r/Startups_EU", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/Startups_EU/", + "submission_url": "https://www.reddit.com/r/Startups_EU/submit", + "logo_url": "https://styles.redditmedia.com/t5_d9o9kl/styles/communityIcon_2hz0ocqttfme1.png?width=256&s=165bd1028414386720b8889c3dbc88288ffdb937", + "description": "Space for any-size startups, and side-projects made in Europe! 🇪🇺\n\nThis subreddit is highly moderated to keep it clean and SPAM-free.", + "audience": "European startup founders sharing projects, ideas, and feedback requests.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "indie", + "startup" + ], + "subreddit": { + "members_count": 17323, + "growth_30d_absolute": 904, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/Startups_EU/about/rules" + } + }, + { + "slug": "startupsolofounder", + "name": "r/StartupSoloFounder", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/StartupSoloFounder/", + "submission_url": "https://www.reddit.com/r/StartupSoloFounder/submit", + "logo_url": "https://styles.redditmedia.com/t5_fn0v9n/styles/communityIcon_dlw5lfn4zxyf1.png?width=256&s=19d159a20bda28f9a3d2e959c97c63bd0e3156ad", + "description": "Welcome to the home for solo founders building startups from scratch \n\nThis community is for those going all in; coding, designing, marketing, validating, and scaling their ideas solo. Whether you’re bootstrapping your first MVP, seeking feedback, sharing progress, or simply surviving the startup grind alone, you’re in the right place.", + "audience": "Solo founders building startups from scratch and sharing their projects.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "indie", + "startup" + ], + "subreddit": { + "members_count": 40268, + "growth_30d_absolute": 4818, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/StartupSoloFounder/about/rules" + } + }, + { + "slug": "stork-ai", + "name": "Stork.AI", + "type": "directory", + "website_url": "https://www.stork.ai/", + "submission_url": "https://www.stork.ai/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=stork.ai&sz=64", + "description": "Stork.AI is an AI tools, agents, and MCP directory with multi-language listings and agent discoverability.", + "audience": "Makers of AI tools, agents, and MCP products seeking directory discovery.", + "eligibility": "Listings must be AI tools, agents, or MCP products submitted with a URL, repository, or package for review.", + "pricing": "free", + "pricing_note": "Submission costs $49 one time, or is free when the Stork Verified badge is added to the submitter's site.", + "link_type": "nofollow", + "domain_rating": 48, + "monthly_traffic": 21100, + "categories": [ + "ai", + "developer-tools" + ] + }, + { + "slug": "stripe", + "name": "Stripe", + "type": "directory", + "website_url": "https://stripe.com", + "submission_url": "https://docs.stripe.com/directory", + "logo_url": "https://www.google.com/s2/favicons?domain=stripe.com&sz=64", + "description": "Stripe is a financial infrastructure and payments platform that also operates a public Directory of businesses built on or integrating with it.", + "audience": "Businesses building on or integrating with Stripe payments, seeking discoverability in Stripe's own Directory.", + "eligibility": "Requires an activated Stripe account that is not on Stripe's Restricted Businesses list to enable a public Stripe Directory profile; the separate Partner Ecosystem application (for deeper partner benefits) has additional track-specific requirements such as certifications or payment volume that this basic Directory route does not need.", + "pricing": "free", + "pricing_note": "Free to create a Stripe account and enable a Directory profile; Stripe's own payment-processing fees apply separately and are not a directory-listing cost.", + "link_type": "nofollow", + "domain_rating": 95, + "monthly_traffic": 4200000, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "superbcrew", + "name": "SuperbCrew", + "type": "citation", + "website_url": "https://www.superbcrew.com/", + "submission_url": "https://www.superbcrew.com/your-voice/", + "logo_url": "https://www.google.com/s2/favicons?domain=superbcrew.com&sz=64", + "description": "SuperbCrew is a business and startup news site that offers company interviews and sponsored guest stories.", + "audience": "Companies with substantive executive, founder, or business stories suitable for a paid editorial-distribution program.", + "eligibility": "There is no public free-listing route; placements use paid ExecutiveVoice or distribution programs arranged by email.", + "pricing": "paid", + "pricing_note": "ExecutiveVoice interviews and guest stories are quoted by email; display advertising is separately priced.", + "link_type": "dofollow", + "domain_rating": 66, + "monthly_traffic": 15700, + "categories": [ + "business", + "marketing", + "startup" + ] + }, + { + "slug": "tailkits", + "name": "Tailkits", + "type": "directory", + "website_url": "https://tailkits.com/", + "submission_url": "https://tailkits.com/submit-product/", + "logo_url": "https://tailkits.com/favicon.png", + "description": "Curated Tailwind CSS marketplace and directory covering templates, UI kits, components, tools, generators, and production-ready design resources.", + "audience": "Creators of Tailwind CSS products seeking discovery among developers and designers actively researching Tailwind templates, components, and utilities.", + "eligibility": "Products must be built with Tailwind CSS, have a unique brand name, professional responsive design, production-ready code, and a working live demo. Unrelated tools, other CSS frameworks, keyword-stuffed names, and design-only assets are rejected.", + "pricing": "free", + "pricing_note": "Free submissions are reviewed within 14 business days. The Booster tier is currently $19, Partner placement $199, and a detailed review $499; rejected paid submissions are refunded.", + "link_type": "unknown", + "domain_rating": 42, + "monthly_traffic": 10300, + "categories": [ + "design", + "developer-tools" + ] + }, + { + "slug": "tapscape", + "name": "Tapscape", + "type": "directory", + "website_url": "https://www.tapscape.com/", + "submission_url": "https://www.tapscape.com/submit-app-review/", + "logo_url": "https://www.tapscape.com/wp-content/uploads/2018/07/tapscapeicons.png", + "description": "Technology publication offering professionally written reviews of iPhone, iPad, Mac, and Android applications with store, company, video, and social links.", + "audience": "Mobile and Mac app publishers seeking an editorial review and exposure to Tapscape's technology readership and social audience.", + "eligibility": "Released iPhone, iPad, Mac, and Android apps may request review. Apple submissions need an App Store link, company URL, and a valid promo code for paid apps; Android submissions need a Google Play link and company URL.", + "pricing": "paid", + "pricing_note": "An expedited written app review costs $99 and includes links to the store and company website plus social promotion. Standard editorial consideration is not guaranteed.", + "link_type": "unknown", + "domain_rating": 50, + "monthly_traffic": 10000, + "categories": [ + "business" + ] + }, + { + "slug": "tech-co", + "name": "Tech.co", + "type": "citation", + "website_url": "https://tech.co/", + "submission_url": "https://tech.co/contact", + "logo_url": "https://www.google.com/s2/favicons?domain=tech.co&sz=64", + "description": "Tech.co publishes technology solutions, software reviews, buying guides, and business advice for professionals and companies.", + "audience": "Technology companies with newsworthy products, launches, or press materials relevant to Tech.co's business technology coverage.", + "eligibility": "Press materials must be relevant to Tech.co's business technology, software, SaaS, reviews, or buying-guide coverage; editorial acceptance is not guaranteed.", + "pricing": "free", + "pricing_note": "Editorial press submissions are free but selective; sponsored posts and partnerships are paid separately.", + "link_type": "dofollow", + "domain_rating": 82, + "monthly_traffic": 30000, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "tech-eu", + "name": "Tech.eu", + "type": "citation", + "website_url": "https://tech.eu/", + "submission_url": "https://tech.eu/advertise/", + "logo_url": "https://www.google.com/s2/favicons?domain=tech.eu&sz=64", + "description": "Tech.eu is a European technology publication covering the startup ecosystem and wider technology industry.", + "audience": "European technology startups and companies with a genuinely newsworthy launch, funding event, or industry story.", + "eligibility": "Editorial coverage depends on a newsworthy European technology story and independent editorial selection; sponsors can separately inquire about paid campaigns.", + "pricing": "free", + "pricing_note": "Editorial pitches are free when selected; sponsored articles, banners, campaigns, and summit sponsorships are paid with rates available on request.", + "link_type": "nofollow", + "domain_rating": 79, + "monthly_traffic": 1800, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "tech-in-asia", + "name": "Tech in Asia", + "type": "citation", + "website_url": "https://www.techinasia.com/", + "submission_url": "https://www.techinasia.com/companies/fundraising", + "logo_url": "https://www.google.com/s2/favicons?domain=techinasia.com&sz=64", + "description": "Tech in Asia is an English-language technology media company covering startups, funding, trends, and news across the Asian tech ecosystem.", + "audience": "Asia-focused startups with a current fundraising round or a market-moving story supported by meaningful traction, growth, or a distinctive angle.", + "eligibility": "Fundraising startups can submit for a reviewed 90-day listing; editorial coverage is selective and needs a timely, relevant Asia technology story with sufficient supporting detail.", + "pricing": "free", + "pricing_note": "The fundraising list and editorial pitches are free when accepted; branded and sponsored content is separately paid.", + "link_type": "nofollow", + "domain_rating": 82, + "monthly_traffic": 102900, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "techbehemoths", + "name": "TechBehemoths", + "type": "directory", + "website_url": "https://techbehemoths.com/", + "submission_url": "https://techbehemoths.com/companies/get-listed", + "logo_url": "https://techbehemoths.com/favicon/apple-touch-icon-152x152.png", + "description": "Global directory of technology agencies and service companies searchable by location, service, industry, team size, hourly rate, and client focus.", + "audience": "Technology agencies, consultancies, freelancers, and development or marketing service companies seeking visibility to buyers comparing providers.", + "eligibility": "Technology service providers and agencies with a real operating business may create or claim a company profile. Applicants must provide accurate company identity, location, services, industries, team size, rates, portfolio, and contact details.", + "pricing": "free", + "pricing_note": "Creating a standard company profile is free. Optional paid visibility and promotional products may be offered separately.", + "link_type": "unknown", + "domain_rating": 77, + "monthly_traffic": 72900, + "categories": [ + "business", + "marketing" + ] + }, + { + "slug": "techcrunch", + "name": "TechCrunch", + "type": "citation", + "website_url": "https://techcrunch.com", + "submission_url": "https://techcrunch.com/got-a-tip/", + "logo_url": "https://www.google.com/s2/favicons?domain=techcrunch.com&sz=64", + "description": "TechCrunch is a technology news site covering startups, venture funding, product launches, AI, and related innovation.", + "audience": "Technology companies with a genuinely newsworthy startup story, such as meaningful funding, product traction, data, or an exclusive angle.", + "eligibility": "There is no self-serve company listing. Pitches must be timely, credible, and relevant to TechCrunch's editorial focus; only a small share receive coverage.", + "pricing": "free", + "pricing_note": "Free to pitch; TechCrunch does not sell editorial coverage.", + "link_type": "dofollow", + "domain_rating": 92, + "monthly_traffic": 1200000, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "technology-advice", + "name": "Technology Advice", + "type": "directory", + "website_url": "https://technologyadvice.com/", + "submission_url": "https://solutions.technologyadvice.com/list-your-product/", + "logo_url": "https://www.google.com/s2/favicons?domain=technologyadvice.com&sz=64", + "description": "Technology Advice is a B2B technology and software reviews, insights, and marketplace platform that helps buyers evaluate products and connect with vendors.", + "audience": "Publicly available technology and software product teams seeking a buyer-facing product profile.", + "eligibility": "Products must be publicly available technology or software offerings; pre-launch and waitlist-only products are generally not accepted.", + "pricing": "free", + "pricing_note": "A basic product listing is free; the Enhanced plan costs $100 per month for features such as intent data, custom branding, and traffic tools.", + "link_type": "nofollow", + "domain_rating": 78, + "monthly_traffic": 208800, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "techpluto", + "name": "TechPluto", + "type": "citation", + "website_url": "https://www.techpluto.com/", + "submission_url": "https://www.techpluto.com/submit-startup/", + "logo_url": "https://www.google.com/s2/favicons?domain=techpluto.com&sz=64", + "description": "TechPluto is a tech and startup news site that reviews and features submitted startups rather than automatically listing every submission.", + "audience": "Startup founders seeking editorial feature coverage from a technology and startup news publication.", + "eligibility": "Editorial selection considers novelty, product vision, impact, technology, innovation, disruption, social or green impact, market buzz, and original copy.", + "pricing": "free", + "pricing_note": "Startup submission is free; an optional Priority Pass is offered for faster handling.", + "link_type": "dofollow", + "domain_rating": 54, + "monthly_traffic": 2500, + "categories": [ + "business", + "marketing", + "startup" + ] + }, + { + "slug": "techradar", + "name": "TechRadar", + "type": "citation", + "website_url": "https://www.techradar.com", + "submission_url": "https://www.techradar.com/how-to/how-to-pitch-your-ideas-to-techradar", + "logo_url": "https://www.google.com/s2/favicons?domain=techradar.com&sz=64", + "description": "Technology publication covering consumer and business technology.", + "audience": "Experts and companies with useful, non-promotional technology insight.", + "eligibility": "Editorial coverage is independently selected; Pro Perspectives articles require approval and must be exclusive and non-promotional.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 91, + "monthly_traffic": 3800000, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "techstars", + "name": "Techstars", + "type": "community", + "website_url": "https://www.techstars.com/", + "submission_url": "https://apply.techstars.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=techstars.com&sz=64", + "description": "Techstars is a global startup accelerator and founder network that invests in early-stage companies and provides mentorship and investor connections.", + "audience": "Early-stage startup founders seeking accelerator investment, mentorship, and access to the Techstars founder and investor network.", + "eligibility": "Startups are selectively evaluated on team, market, progress or traction, and idea, with individual programs adding their own geographic, vertical, stage, or funding criteria.", + "pricing": "free", + "pricing_note": "Applying is free; accepted companies receive investment in exchange for equity under Techstars' published program terms.", + "link_type": "dofollow", + "domain_rating": 82, + "monthly_traffic": 20900, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "tekpon", + "name": "Tekpon", + "type": "directory", + "website_url": "https://tekpon.com/", + "submission_url": "https://tekpon.com/get-listed/", + "logo_url": "https://www.google.com/s2/favicons?domain=tekpon.com&sz=64", + "description": "Tekpon is a SaaS procurement and software marketplace that lists tools with profiles, comparisons, reviews, pricing transparency, and buyer-side procurement services.", + "audience": "SaaS and software vendors that want a complete public product profile for buyer discovery and marketplace visibility.", + "eligibility": "Software listings require complete product details including name, concise description, best-for or USP information, company-size recommendations, FAQs, pricing, features, integrations, and contact details; existing listings can be claimed after account verification.", + "pricing": "paid", + "pricing_note": "A fully optimized product profile costs a one-time US$249 fee.", + "link_type": "dofollow", + "domain_rating": 72, + "monthly_traffic": 2900, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "testmyapp", + "name": "r/TestMyApp", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/TestMyApp/", + "submission_url": "https://www.reddit.com/r/TestMyApp/submit", + "logo_url": "https://styles.redditmedia.com/t5_37hmj/styles/communityIcon_22j5hexmopx31.png?width=256&s=12393554cdd6ed8585b05734a52b52bcdbfc62a0", + "description": "Helping developers since 2015 \nTMA Discord: https://discord.gg/9wGENxCZrN\n\nDonate to my coffee fund if you found this sub useful :) \nOnly if you can afford it!! Link:\nhttps://cash.app/$CrabOfTheSea\n\nShowcase Apps Here!\nIn this subreddit, we proudly help developers to showcase their hard work on any app platform they please! This subreddit is here to help get the news out on new projects. Enjoy!", + "audience": "Developers and app makers seeking to showcase new apps and get testing feedback from the community.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "community", + "developer-tools" + ], + "subreddit": { + "members_count": 29312, + "growth_30d_absolute": 1705, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/TestMyApp/about/rules" + } + }, + { + "slug": "that-ai-collection", + "name": "That AI Collection", + "type": "directory", + "website_url": "https://thataicollection.com/", + "submission_url": "https://thataicollection.com/submit/", + "logo_url": "https://thataicollection.com/favicon.ico", + "description": "Multilingual discovery directory covering more than 4,000 AI applications across writing, design, development, marketing, audio, video, productivity, and other use cases.", + "audience": "AI product makers seeking directory discovery, a do-follow website link, launch visibility, and optional editorial promotion.", + "eligibility": "Submissions must be AI applications. The guided flow requires a valid product URL, drafts the listing with AI, and allows every field to be reviewed before submission.", + "pricing": "paid", + "pricing_note": "Publication requires a small fee. Available promotion plans and prices are shown on the payment page after the submission details are completed.", + "link_type": "unknown", + "domain_rating": 43, + "monthly_traffic": 1000, + "categories": [ + "ai" + ] + }, + { + "slug": "the-ai-navigator", + "name": "The AI Navigator", + "type": "directory", + "website_url": "https://www.theainavigator.com/", + "submission_url": "https://www.theainavigator.com/submit-an-ai-tool", + "logo_url": "https://www.google.com/s2/favicons?domain=theainavigator.com&sz=64", + "description": "The AI Navigator is an AI-tools guide and directory with reviewed listings.", + "audience": "Makers of AI products and custom GPTs seeking reviewed directory discovery.", + "eligibility": "The main form accepts AI tools; the custom-GPT form evaluates models for innovation, usability, and value before editorial approval.", + "pricing": "unknown", + "pricing_note": "The public tool-submission page does not state a listing fee; the site's $30 AI-terms guide is not a listing charge.", + "link_type": "dofollow", + "domain_rating": 19, + "monthly_traffic": 7500, + "categories": [ + "ai", + "productivity", + "startup" + ] + }, + { + "slug": "the-fastlane-forum", + "name": "The Fastlane Forum", + "type": "citation", + "website_url": "https://www.thefastlaneforum.com/community/", + "submission_url": "https://www.thefastlaneforum.com/community/threads/self-promotion-post-your-offer-products-or-services-2025-26.106896/", + "logo_url": "https://www.google.com/s2/favicons?domain=thefastlaneforum.com&sz=64", + "description": "The Fastlane Forum is MJ DeMarco's entrepreneur discussion community, not a product-launch directory.", + "audience": "Founders and software makers who can contribute genuinely useful entrepreneurship discussion rather than cold promotion.", + "eligibility": "The self-promotion route is for members using the designated thread, limited to one post per week unless someone replies; indiscriminate SEO or spam links, main-forum promotion, MLM, make-money-online packages, and investor solicitation are prohibited.", + "pricing": "free", + "pricing_note": "The designated self-promotion thread is available to members. Producer costs $47/year for immediate signature-link permissions and Insider costs $119.88/year; Marketplace Ads are paid, but the exact price was not visible during research.", + "link_type": "nofollow", + "domain_rating": 49, + "monthly_traffic": 1700, + "categories": [ + "business", + "community", + "startup" + ] + }, + { + "slug": "the-hive-index", + "name": "The Hive Index", + "type": "community", + "website_url": "https://thehiveindex.com/", + "submission_url": "https://thehiveindex.com/submit/", + "logo_url": "https://www.google.com/s2/favicons?domain=thehiveindex.com&sz=64", + "description": "The Hive Index is a directory of online communities organized by topic, platform, and features.", + "audience": "Owners of active online communities seeking topic-based discovery and directory visibility.", + "eligibility": "Communities must be active, have at least 10 members, offer chat or forum-style features, be non-NSFW and non-violent, and match an existing topic.", + "pricing": "free", + "pricing_note": "A free listing is permanent on up to two topic pages; Featured costs $99 per month and Gold costs $390 per year.", + "link_type": "dofollow", + "domain_rating": 51, + "monthly_traffic": 16500, + "categories": [ + "community" + ] + }, + { + "slug": "the-startup-inc", + "name": "The Startup INC", + "type": "directory", + "website_url": "https://thestartupinc.com/", + "submission_url": "https://thestartupinc.com/submit-startup/", + "logo_url": "https://thestartupinc.com/wp-content/uploads/2018/03/the-startup-inc.jpg", + "description": "Startup listing website publishing company profiles for discovery by founders, customers, and investors.", + "audience": "Startup teams seeking a searchable profile, website backlink, and exposure to an India-heavy startup readership.", + "eligibility": "Applicants must submit a startup website and original company content. Copied or duplicate descriptions are not published, and every application is reviewed before publication.", + "pricing": "paid", + "pricing_note": "Paid listing. The page prominently states $20 after review and publication, while an older FAQ on the same page says $10; confirm the current charge before proceeding.", + "link_type": "unknown", + "domain_rating": 33, + "monthly_traffic": 1800, + "categories": [ + "business" + ] + }, + { + "slug": "the-startup-project", + "name": "The Startup Project", + "type": "directory", + "website_url": "https://startupproject.org/", + "submission_url": "https://startupproject.org/submit-startup/", + "logo_url": "https://startupproject.org/favicon.ico", + "description": "Founder resource hub with a curated startup directory alongside free templates, tools, guides, learning paths, and investor resources.", + "audience": "Startup founders seeking a permanent company profile and discovery among founders, investors, and startup-resource researchers.", + "eligibility": "Startup submissions are manually reviewed and publication is not guaranteed. Published companies must link back to their listing from their own website to keep the listing active.", + "pricing": "free", + "pricing_note": "Free listing. Review normally takes a few business days; a backlink from the startup website to its directory profile is required to maintain publication.", + "link_type": "unknown", + "domain_rating": 21, + "monthly_traffic": 1000, + "categories": [ + "business" + ] + }, + { + "slug": "the-verge", + "name": "The Verge", + "type": "citation", + "website_url": "https://www.theverge.com", + "submission_url": "https://www.theverge.com/write-for-the-verge", + "logo_url": "https://www.google.com/s2/favicons?domain=theverge.com&sz=64", + "description": "The Verge is a technology news and culture publication covering gadgets, software, science, policy, and technology's effect on everyday life.", + "audience": "Writers and organizations with strong, specific technology stories supported by reporting or distinctive insight; not a company-listing route.", + "eligibility": "Pitches need a narrative-backed technology or science angle, reporting plan, estimated word count, and relevant past work. Product pitches without a deeper story and executive op-eds are not accepted.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 92, + "monthly_traffic": 455300, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "theres-an-ai-for-that", + "name": "There's An AI For That", + "type": "directory", + "website_url": "https://theresanaiforthat.com", + "submission_url": "https://theresanaiforthat.com/launch/", + "logo_url": "https://theresanaiforthat.com/apple-touch-icon.png", + "description": "The largest AI tools directory, tracking AI tools, models, and news in real time for millions of monthly visitors.", + "audience": "AI enthusiasts and professionals searching for AI tools by task", + "eligibility": "AI tools, GPTs, and non-human AI influencers; all submissions go through manual staff review.", + "pricing": "paid", + "pricing_note": "Basic directory listing costs a $49 one-time review fee; a $347 maximum-exposure package adds a newsletter feature.", + "link_type": "dofollow", + "domain_rating": 77, + "monthly_traffic": 1000000, + "categories": [ + "ai", + "saas" + ] + }, + { + "slug": "time-best-inventions", + "name": "TIME Best Inventions", + "type": "citation", + "website_url": "https://time.com", + "submission_url": "https://bestinventions.time.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=time.com&sz=64", + "description": "TIME is a global news magazine and website that publishes annual lists including Best Inventions and TIME100 Companies.", + "audience": "Companies and creators with a product, software, or service that reached a significant milestone during TIME's stated eligibility period.", + "eligibility": "Applicants submit a qualifying invention, company, or service with a recent major milestone, supporting statements, images, and category details. TIME's editorial team makes final selections and payment does not guarantee inclusion.", + "pricing": "paid", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 92, + "monthly_traffic": 2700000, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "tinylaunch", + "name": "TinyLaunch", + "type": "product-launch-site", + "website_url": "https://www.tinylaunch.com/", + "submission_url": "https://www.tinylaunch.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=tinylaunch.com&sz=64", + "description": "TinyLaunch is a product-launch platform for indie projects and startups that offers community voting, badges, and backlinks.", + "audience": "Makers launching their own indie projects, side projects, or startups.", + "eligibility": "Submitters may launch only products they own, must not use trash-mail accounts or manipulate votes, and may relaunch the same product only after three months.", + "pricing": "free", + "pricing_note": "A free standard launch is available; Premium Launch costs US$39 and featured placements or a directory-submission service are paid options.", + "link_type": "dofollow", + "domain_rating": 73, + "monthly_traffic": 1900, + "categories": [ + "indie", + "startup" + ] + }, + { + "slug": "tipseason", + "name": "TipSeason", + "type": "directory", + "website_url": "https://www.tipseason.com/", + "submission_url": "https://www.tipseason.com/ai-tools/submit-free", + "logo_url": "https://www.google.com/s2/favicons?domain=tipseason.com&sz=64", + "description": "TipSeason runs an AI-tools directory alongside prompts and courses.", + "audience": "Makers of AI products seeking AI-tool directory discovery and optional featured placement.", + "eligibility": "Eligible listings are AI tools; the free submission form requires the tool's category.", + "pricing": "free", + "pricing_note": "The directory offers a free queue listing and a $99 paid priority review; the site presents the free queue as up to 90 days and the paid review as 48 hours, although some on-page timing copy is inconsistent.", + "link_type": "dofollow", + "domain_rating": 44, + "monthly_traffic": 1300, + "categories": [ + "ai", + "productivity", + "startup" + ] + }, + { + "slug": "tool-finder", + "name": "Tool Finder", + "type": "directory", + "website_url": "https://toolfinder.com/", + "submission_url": "https://toolfinder.com/advertise", + "logo_url": "https://www.google.com/s2/favicons?domain=toolfinder.com&sz=64", + "description": "Tool Finder is a productivity-software discovery site with a curated tools database, comparisons, deals, YouTube content, and a newsletter.", + "audience": "Vendors of genuine software tools across supported categories seeking a human-reviewed listing.", + "eligibility": "Tools must pass human review; low-quality, low-security, crypto, stablecoin, finance, and NSFW tools are excluded.", + "pricing": "paid", + "pricing_note": "A basic listing costs $29 one time; Vendor Pro costs $79 per year and includes the listing, with a full refund if a submission is rejected.", + "link_type": "nofollow", + "domain_rating": 60, + "monthly_traffic": 3900, + "categories": [ + "productivity", + "saas" + ] + }, + { + "slug": "toolify-ai", + "name": "Toolify.ai", + "type": "directory", + "website_url": "https://www.toolify.ai/", + "submission_url": "https://www.toolify.ai/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=toolify.ai&sz=64", + "description": "Toolify.ai is an AI-tools directory that lists and categorizes AI products for discovery and traffic.", + "audience": "Makers of AI tools seeking a permanent directory listing and product discovery.", + "eligibility": "Listings are for AI tools submitted through the directory's official submission flow.", + "pricing": "paid", + "pricing_note": "The official submission page lists a one-time US$99 fee and does not show a free listing option.", + "link_type": "nofollow", + "domain_rating": 72, + "monthly_traffic": 53800, + "categories": [ + "ai" + ] + }, + { + "slug": "toolpilot-ai", + "name": "ToolPilot.ai", + "type": "directory", + "website_url": "https://www.toolpilot.ai/", + "submission_url": "https://www.toolpilot.ai/pages/submit-your-ai-tool", + "logo_url": "https://www.google.com/s2/favicons?domain=toolpilot.ai&sz=64", + "description": "ToolPilot.ai is a directory where users discover, compare, review, and explore AI tools and services.", + "audience": "Creators of AI tools and services that meet ToolPilot.ai's directory quality standards.", + "eligibility": "Submitted products must be AI tools or services; the free tier requires confirming the required backlink, badge, or logo placement on the submitter's site.", + "pricing": "free", + "pricing_note": "The free tier requires a backlink or badge and can take up to about 90 days; Priority is about US$99 one-time and Premium starts about US$199 plus US$39 monthly for featured placement.", + "link_type": "dofollow", + "domain_rating": 78, + "monthly_traffic": 5300, + "categories": [ + "ai" + ] + }, + { + "slug": "toolspedia", + "name": "ToolsPedia", + "type": "directory", + "website_url": "https://toolspedia.io/", + "submission_url": "https://toolspedia.io/submit-tool/", + "logo_url": "https://toolspedia.io/wp-content/uploads/2023/01/ToolsPedia-AI-Fevion-image-300x300.png", + "description": "AI tools directory with thousands of categorized product pages, discovery collections, browser-extension listings, and featured placements.", + "audience": "AI product makers seeking a permanent directory profile and discovery among users researching named AI applications and alternatives.", + "eligibility": "Submitted products must be AI tools. Every paid submission is reviewed by the editorial team; rejected submissions are refunded except for payment-processing fees, and prohibited violent or sexually explicit content is not published.", + "pricing": "paid", + "pricing_note": "One-time lifetime listing: Basic $49, Pro $79, or Elite $149. There are no recurring listing fees; editorial approval is still required.", + "link_type": "unknown", + "domain_rating": 13, + "monthly_traffic": 1400, + "categories": [ + "ai" + ] + }, + { + "slug": "toools-design", + "name": "TOOOLS.design", + "type": "directory", + "website_url": "https://www.toools.design/", + "submission_url": "https://www.toools.design/suggest-a-tool", + "logo_url": "https://www.google.com/s2/favicons?domain=toools.design&sz=64", + "description": "Curated directory of more than 2,200 design resources spanning AI, UI and UX, icons, illustrations, inspiration, typography, productivity, marketing, and web builders.", + "audience": "Creators of genuinely useful design tools and resources seeking discovery among working designers and product teams.", + "eligibility": "Only design-related and genuinely useful tools are considered. Every suggestion is reviewed, and accepted resources may take from a few days to several weeks to publish.", + "pricing": "free", + "pricing_note": "Free suggestion and editorial review; publication is selective and timing is not guaranteed.", + "link_type": "unknown", + "domain_rating": 47, + "monthly_traffic": 6900, + "categories": [ + "design" + ] + }, + { + "slug": "topai-tools", + "name": "TopAI.tools", + "type": "directory", + "website_url": "https://topai.tools/", + "submission_url": "https://topai.tools/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=topai.tools&sz=64", + "description": "TopAI.tools is an AI-tools directory indexing thousands of tools across more than 120 categories for search, comparison, and discovery.", + "audience": "Builders of functional AI-powered tools and applications seeking directory discovery.", + "eligibility": "Submissions must be accessible, fully functional AI-powered tools or applications with clear AI features and user value; TopAI.tools reviews each listing and refunds rejected paid submissions.", + "pricing": "paid", + "pricing_note": "There is no free tier: Fast Track costs US$47 for a permanent listing, while Featured costs US$229 for additional homepage and category placement.", + "link_type": "nofollow", + "domain_rating": 65, + "monthly_traffic": 25200, + "categories": [ + "ai" + ] + }, + { + "slug": "toptools-ai", + "name": "TopTools.ai", + "type": "directory", + "website_url": "https://toptools.ai/", + "submission_url": "https://toptools.ai/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=toptools.ai&sz=64", + "description": "AI tools directory organizing product profiles across thirty categories including agents, chatbots, design, marketing, research, coding, video, audio, and productivity.", + "audience": "AI software makers seeking discovery among users searching for named AI products and category-specific alternatives.", + "eligibility": "Only AI tools are accepted; newsletters, directories, and similar non-tool submissions are explicitly rejected. The form requires the tool name, website, and submitter email.", + "pricing": "free", + "pricing_note": "The current public submission form does not request payment or advertise a paid tier; listing remains subject to review.", + "link_type": "unknown", + "domain_rating": 30, + "monthly_traffic": 1300, + "categories": [ + "ai" + ] + }, + { + "slug": "tracxn", + "name": "Tracxn", + "type": "citation", + "website_url": "https://tracxn.com/", + "submission_url": "https://tracxn.com/signup", + "logo_url": "https://www.google.com/s2/favicons?domain=tracxn.com&sz=64", + "description": "Tracxn is a private-market intelligence platform tracking startups and private companies, their funding, investors, and sector data.", + "audience": "Authorized owners or representatives of private companies seeking to claim or contribute their company information.", + "eligibility": "The claimant must be the rightful owner or an authorized representative; Tracxn may accept or reject claims and contributions after review and verification.", + "pricing": "free", + "pricing_note": "Claiming and contributions are voluntary and free; the platform also has a free Lite plan and paid enterprise access.", + "link_type": "dofollow", + "domain_rating": 78, + "monthly_traffic": 1400000, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "trendshift", + "name": "Trendshift", + "type": "community", + "website_url": "https://trendshift.io", + "submission_url": "https://trendshift.io", + "logo_url": "https://www.google.com/s2/favicons?domain=trendshift.io&sz=64", + "description": "Trendshift tracks and showcases trending GitHub repositories and developers.", + "audience": "Makers with a public GitHub repository who want it monitored and surfaced to people discovering trending open-source projects.", + "eligibility": "Submit a public GitHub repository that Trendshift is not already tracking by entering owner/repo or its GitHub URL. Submission starts monitoring and does not guarantee a prominent trending placement.", + "pricing": "unknown", + "pricing_note": "Trendshift does not publish a fee for repository submission. Its Signal API and advertising products are separate paid offerings.", + "link_type": "unknown", + "domain_rating": 63, + "monthly_traffic": 2000, + "categories": [ + "community", + "developer-tools" + ] + }, + { + "slug": "tripadvisor", + "name": "Tripadvisor", + "type": "citation", + "website_url": "https://www.tripadvisor.com", + "submission_url": "https://www.tripadvisor.com/CreateListing.html", + "logo_url": "https://www.google.com/s2/favicons?domain=tripadvisor.com&sz=64", + "description": "Tripadvisor is a travel guidance platform with user reviews and listings for hotels, restaurants, attractions, and experiences.", + "audience": "Travel-related businesses with a permanent, publicly accessible location or experience; not online-only software companies.", + "eligibility": "Listings must be accommodations, restaurants, or things to do that are open to the public regularly and relevant to travelers. Online-only SaaS and non-physical businesses do not qualify.", + "pricing": "free", + "pricing_note": "Claiming or requesting a basic listing is free for eligible businesses; paid marketing products are optional and separate.", + "link_type": "nofollow", + "domain_rating": 93, + "monthly_traffic": 61600000, + "categories": [ + "business" + ] + }, + { + "slug": "trustmrr", + "name": "TrustMRR", + "type": "directory", + "website_url": "https://trustmrr.com/", + "submission_url": "https://trustmrr.com/dashboard", + "logo_url": "https://www.google.com/s2/favicons?domain=trustmrr.com&sz=64", + "description": "TrustMRR is a verified startup-revenue database and acquisition marketplace for SaaS, apps, and digital businesses.", + "audience": "Startups, SaaS businesses, and apps with measurable revenue that want a verified revenue listing or acquisition-marketplace presence.", + "eligibility": "A listing requires a supported payment provider connected with a read-only or scoped API key for revenue verification; no other verification method is available.", + "pricing": "free", + "pricing_note": "The base verified-revenue listing is free; paid add-ons include dofollow and visibility upgrades, while marketplace sale plans start at US$29.", + "link_type": "dofollow", + "domain_rating": 71, + "monthly_traffic": 11300, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "trustpilot", + "name": "Trustpilot", + "type": "directory", + "website_url": "https://trustpilot.com", + "submission_url": "https://business.trustpilot.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=trustpilot.com&sz=64", + "description": "Open consumer-review platform where businesses claim profiles to display, collect, and respond to customer reviews.", + "audience": "Businesses wanting to build public review reputation and consumers researching services.", + "eligibility": "A business needs an active website to create or claim a Trustpilot business profile.", + "pricing": "free", + "pricing_note": "Free claim/manage plan; paid plans are optional.", + "link_type": "nofollow", + "domain_rating": 94, + "monthly_traffic": 59700000, + "categories": [ + "business" + ] + }, + { + "slug": "trustradius", + "name": "TrustRadius", + "type": "directory", + "website_url": "https://www.trustradius.com", + "submission_url": "https://solutions.trustradius.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=trustradius.com&sz=64", + "description": "B2B technology review and buyer-intelligence platform where software vendors build product presence and customer-review programs.", + "audience": "B2B software vendors seeking buyer discovery and business technology buyers researching products.", + "eligibility": "B2B software vendors seeking a TrustRadius product presence and buyer-review program.", + "pricing": "free", + "pricing_note": "Qualifying products can be listed for free, while Customer Voice and add-ons are paid.", + "link_type": "dofollow", + "domain_rating": 84, + "monthly_traffic": 60200, + "categories": [ + "business", + "saas" + ] + }, + { + "slug": "uneed", + "name": "Uneed", + "type": "product-launch-site", + "website_url": "https://www.uneed.best/", + "submission_url": "https://www.uneed.best/submit-a-tool", + "logo_url": "https://www.google.com/s2/favicons?domain=uneed.best&sz=64", + "description": "Uneed is a curated social launchpad and product-discovery directory where makers launch technology products for daily homepage exposure, community upvoting, and rankings.", + "audience": "Makers of technology products across development, design, marketing, business, personal-life, and product-for-sale categories.", + "eligibility": "Products undergo moderation and may be declined for category saturation; paid launches are guaranteed, while the free waiting line is currently closed to new products.", + "pricing": "paid", + "pricing_note": "The free waiting line is closed to new products; paid launch options include roughly US$4.99 to join the line, US$14.99 fast-track, or US$29.99 to skip the line, with guaranteed launch slots and a permanent backlink.", + "link_type": "dofollow", + "domain_rating": 75, + "monthly_traffic": 7900, + "categories": [ + "indie", + "startup" + ] + }, + { + "slug": "unita", + "name": "Unita", + "type": "community", + "website_url": "https://unita.co/", + "submission_url": "https://unita.co/add-community/", + "logo_url": "https://www.google.com/s2/favicons?domain=unita.co&sz=64", + "description": "Unita is a directory for discovering, comparing, and reviewing online communities and groups.", + "audience": "Owners of online communities, masterminds, and groups seeking directory visibility and verification.", + "eligibility": "Online communities, masterminds, and groups can be submitted by their owners for listing and verification.", + "pricing": "free", + "pricing_note": "Community owners can add a community for free; public materials mention optional verification and badge options.", + "link_type": "dofollow", + "domain_rating": 47, + "monthly_traffic": 4000, + "categories": [ + "community" + ] + }, + { + "slug": "vccircle", + "name": "VCCircle", + "type": "citation", + "website_url": "https://www.vccircle.com/", + "submission_url": "https://thepitch.vccircle.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=vccircle.com&sz=64", + "description": "VCCircle is an Indian publication covering alternative investments, deals, mergers and acquisitions, venture capital, private equity, and startups.", + "audience": "Indian startups with a complete pitch and sufficient business context for VCCircle's selective The Pitch program.", + "eligibility": "Startups are shortlisted after nomination, pitch-deck review, and due diligence; selection and fees depend on funding stage and revenue category.", + "pricing": "free", + "pricing_note": "Nomination is free; shortlisted companies pay fees that vary by funding stage and revenue category, ranging from about Rs. 20,000 to Rs. 51,000 plus GST.", + "link_type": "nofollow", + "domain_rating": 77, + "monthly_traffic": 12900, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "venture-radar", + "name": "VentureRadar", + "type": "directory", + "website_url": "https://www.ventureradar.com/", + "submission_url": "https://www.ventureradar.com/add_company", + "logo_url": "https://www.google.com/s2/favicons?domain=ventureradar.com&sz=64", + "description": "VentureRadar is a global innovative-company database used by corporations, investors, and partners to research companies.", + "audience": "Innovative companies, startups, and SMEs seeking a company-profile listing for corporate, investor, and partner discovery.", + "eligibility": "Companies must create a free VentureRadar account and provide their name, website, LinkedIn profile, company type, founding year, headquarters, description, short descriptor, and up to six sector keywords.", + "pricing": "free", + "pricing_note": "The standard company profile is free and reviewed, typically within 21 business days. The add-company form also offers a $75 Premium Profile at the observed 50% lifetime discount for instant verification and extra profile sections.", + "link_type": "dofollow", + "domain_rating": 51, + "monthly_traffic": 7100, + "categories": [ + "business", + "saas", + "startup" + ] + }, + { + "slug": "venturebeat", + "name": "VentureBeat", + "type": "citation", + "website_url": "https://venturebeat.com", + "submission_url": "https://venturebeat.com/guest-posts/", + "logo_url": "https://www.google.com/s2/favicons?domain=venturebeat.com&sz=64", + "description": "VentureBeat is a technology publication covering enterprise technology, AI, data infrastructure, security, and startups.", + "audience": "Experts and companies with original, non-promotional enterprise-technology insight or genuinely newsworthy technology stories.", + "eligibility": "Guest posts must be original, non-promotional, and useful to enterprise readers; news coverage remains editorially selective.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 91, + "monthly_traffic": 10400, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "visual-studio-marketplace", + "name": "Visual Studio Marketplace", + "type": "marketplace", + "website_url": "https://marketplace.visualstudio.com/VSCode", + "submission_url": "https://code.visualstudio.com/api/working-with-extensions/publishing-extension", + "logo_url": "https://marketplace.visualstudio.com/favicon.ico", + "description": "Microsoft's marketplace for Visual Studio Code extensions and other Visual Studio and Azure DevOps integrations.", + "audience": "Developers distributing a production-ready VS Code extension or another supported Visual Studio Marketplace integration.", + "eligibility": "A VS Code extension needs a Marketplace publisher identity, valid package metadata, compliant assets and content, and a package that passes publishing checks.", + "pricing": "free", + "pricing_note": "VS Code Marketplace extensions default to the Free pricing label; publishers can also identify a Free Trial. The official publishing workflow does not state a listing fee.", + "link_type": "dofollow", + "domain_rating": 92, + "monthly_traffic": 211600, + "categories": [ + "developer-tools", + "productivity" + ] + }, + { + "slug": "vuejs", + "name": "r/vuejs", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/vuejs/", + "submission_url": "https://www.reddit.com/r/vuejs/submit", + "logo_url": "https://styles.redditmedia.com/t5_38jhw/styles/communityIcon_pwbuual1yf5b1.jpg?width=256&s=d491e197336c031f045d91fa55d410c68abc5751", + "description": "Vue.js is a library for building interactive web interfaces. It provides data-reactive components with a simple and flexible API.", + "audience": "Vue developers sharing work and products with restraint.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 134892, + "growth_30d_absolute": 645, + "promotion_mode": "direct_post", + "rules_url": "https://www.reddit.com/r/vuejs/about/rules" + } + }, + { + "slug": "wall-street-journal", + "name": "The Wall Street Journal", + "type": "citation", + "website_url": "https://www.wsj.com", + "submission_url": "https://www.wsj.com/tips", + "logo_url": "https://www.google.com/s2/favicons?domain=wsj.com&sz=64", + "description": "The Wall Street Journal is a major American newspaper covering business, finance, markets, and economic news.", + "audience": "Companies and experts with genuinely newsworthy business information, proprietary insight, or a credible exclusive; not a self-serve startup-listing channel.", + "eligibility": "Coverage is selective and editorial. Tips need significant news value, supporting evidence, or a strong exclusive; ordinary product announcements alone are rarely a fit.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 92, + "monthly_traffic": 4600000, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "we-work-remotely", + "name": "We Work Remotely", + "type": "marketplace", + "website_url": "https://weworkremotely.com/", + "submission_url": "https://weworkremotely.com/employers", + "logo_url": "https://www.google.com/s2/favicons?domain=weworkremotely.com&sz=64", + "description": "We Work Remotely is a remote-job board where companies post openings for a global audience of remote workers.", + "audience": "Companies with legitimate remote or location-restricted remote roles that need to reach remote job seekers.", + "eligibility": "Employers can post paid remote job listings when the role complies with We Work Remotely's legal, non-discriminatory, and anti-scam community guidelines.", + "pricing": "paid", + "pricing_note": "A 30-day job listing starts at US$299, with optional visibility upgrades, bundles, and first-post discounts.", + "link_type": "dofollow", + "domain_rating": 79, + "monthly_traffic": 222500, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "web-design-inspiration", + "name": "Web Design Inspiration", + "type": "directory", + "website_url": "https://www.webdesign-inspiration.com/", + "submission_url": "https://www.webdesign-inspiration.com/web-design-site/submit", + "logo_url": "https://www.google.com/s2/favicons?domain=webdesign-inspiration.com&sz=64", + "description": "Web Design Inspiration is a curated gallery of handpicked website designs organized by type, style, color, and industry.", + "audience": "Designers, agencies, and owners of polished public websites seeking inclusion in a curated design gallery.", + "eligibility": "Submitted websites must have clean web design, strong UX, and standout visual presentation; editorial review applies.", + "pricing": "free", + "pricing_note": "Standard submission is free but may take months and typically provides a nofollow link; Express Review costs US$50 for review within 48 hours and a dofollow backlink.", + "link_type": "nofollow", + "domain_rating": 66, + "monthly_traffic": 3900, + "categories": [ + "design" + ] + }, + { + "slug": "web-designer-news", + "name": "Web Designer News", + "type": "citation", + "website_url": "https://webdesignernews.com/", + "submission_url": "https://webdesignernews.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=webdesignernews.com&sz=64", + "description": "Web Designer News is a curated collection of current news stories for web designers and developers.", + "audience": "Teams with a relevant web design, development, UX, app, or graphic-design article they want editorially reviewed by a design audience.", + "eligibility": "Submissions must be new articles relevant to web design, development, UX, apps, or graphic design; homepage submissions and spammy affiliate links are not accepted.", + "pricing": "free", + "pricing_note": "Article submissions are free and require editorial approval.", + "link_type": "dofollow", + "domain_rating": 59, + "monthly_traffic": 2600, + "categories": [ + "design", + "developer-tools" + ] + }, + { + "slug": "webdev", + "name": "r/webdev", + "type": "subreddit", + "website_url": "https://www.reddit.com/r/webdev/", + "submission_url": "https://www.reddit.com/r/webdev/submit", + "logo_url": "https://b.thumbs.redditmedia.com/vk8EAqzcLRGYh_Yisi68CglMMuheNEFKNaDLZy7h2ZE.png", + "description": "A community dedicated to all things web development: both front-end and back-end. For more design-related questions, try /r/web_design.", + "audience": "Web developers sharing technical project work and receiving feedback.", + "eligibility": null, + "pricing": "unknown", + "pricing_note": null, + "link_type": "unknown", + "domain_rating": null, + "monthly_traffic": null, + "categories": [ + "developer-tools", + "indie" + ], + "subreddit": { + "members_count": 3305212, + "growth_30d_absolute": 13620, + "promotion_mode": "promotion_thread", + "rules_url": "https://www.reddit.com/r/webdev/about/rules" + } + }, + { + "slug": "webrazzi", + "name": "Webrazzi", + "type": "product-launch-site", + "website_url": "https://webrazzi.com/en/", + "submission_url": "https://webrazzi.com/en/startup-form/", + "logo_url": "https://www.google.com/s2/favicons?domain=webrazzi.com&sz=64", + "description": "Technology publication covering startups, digital business, investments, acquisitions, mobile products, and internet companies, with a strong Turkish audience.", + "audience": "Startup founders seeking editorial review and potential coverage by a long-running technology publication focused on Turkey's startup ecosystem.", + "eligibility": "The pitch form is only for review of new startups. News announcements and updates about existing startups must be sent separately to the editorial team and are not accepted through this form.", + "pricing": "free", + "pricing_note": "The public startup-review form does not state or request a fee. Submission is an editorial pitch and does not guarantee coverage.", + "link_type": "unknown", + "domain_rating": 74, + "monthly_traffic": 15600, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "wellfound", + "name": "Wellfound", + "type": "marketplace", + "website_url": "https://wellfound.com", + "submission_url": "https://wellfound.com/recruit/all-features/post-a-job", + "logo_url": "https://www.google.com/s2/favicons?domain=wellfound.com&sz=64", + "description": "Wellfound is a startup talent marketplace where companies create profiles and post jobs to reach candidates.", + "audience": "Startups and technology companies at any stage that want a public company profile and access to startup-focused talent.", + "eligibility": "Companies must create a profile to post jobs; Wellfound supports companies from pre-seed through post-IPO stages.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 87, + "monthly_traffic": 236200, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "whats-the-big-data", + "name": "What's the Big Data", + "type": "directory", + "website_url": "https://whatsthebigdata.com/", + "submission_url": "https://whatsthebigdata.com/submit-new-ai-tool/", + "logo_url": "https://www.google.com/s2/favicons?domain=whatsthebigdata.com&sz=64", + "description": "What's the Big Data is an online destination for big-data, AI, and machine-learning news, guides, events, expert insights, and an AI-tools directory.", + "audience": "Makers of functional, accessible AI tools with a clear value proposition.", + "eligibility": "Tools must meet the directory's quality standards and cannot be spammy, misleading, illegal, or harmful; every submission receives manual review.", + "pricing": "free", + "pricing_note": "A free listing is available when the site displays the directory's featured badge; paid Basic, Featured, and NSFW listing options are also available.", + "link_type": "nofollow", + "domain_rating": 74, + "monthly_traffic": 95300, + "categories": [ + "ai" + ] + }, + { + "slug": "whattheai", + "name": "WhatTheAI", + "type": "directory", + "website_url": "https://whattheai.tech/", + "submission_url": "https://whattheai.tech/submit", + "logo_url": "https://whattheai.tech/WTAILogo.png", + "description": "AI tool directory for discovering and comparing products across categories, platforms, pricing models, and use cases.", + "audience": "AI product makers seeking discovery through a tool directory whose organic audience visits individual AI-tool profiles.", + "eligibility": "Submitters must sign in and provide the tool name, descriptions, website, submitter identity, a suitable logo, at least one category and feature, supported platforms, pricing information, and association disclosure.", + "pricing": "free", + "pricing_note": "A regular listing is free and enters the standard review queue. Optional paid upgrades include a $49 one-time fast-track listing and additional promotional offers.", + "link_type": "unknown", + "domain_rating": 34, + "monthly_traffic": 22400, + "categories": [ + "ai" + ] + }, + { + "slug": "wikipedia", + "name": "Wikipedia", + "type": "citation", + "website_url": "https://en.wikipedia.org", + "submission_url": "https://en.wikipedia.org/wiki/Wikipedia:Conflict_of_interest", + "logo_url": "https://www.google.com/s2/favicons?domain=en.wikipedia.org&sz=64", + "description": "Wikipedia is a free, collaboratively edited online encyclopedia anyone can contribute to under its content policies.", + "audience": "Any company or product with sustained, independent press coverage — not a self-nomination channel for unestablished startups.", + "eligibility": "Notability — sustained coverage in independent, reliable secondary sources (WP:NCORP / WP:ORG); a company cannot self-certify this. Trivial mentions, press releases, sponsored content, and the company's own materials do not count.", + "pricing": "free", + "pricing_note": "Free; there is no fee or paid tier, only the notability and disclosure bar.", + "link_type": "nofollow", + "domain_rating": 97, + "monthly_traffic": 2100000000, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "wip", + "name": "WIP", + "type": "community", + "website_url": "https://wip.co/", + "submission_url": "https://wip.co/projects", + "logo_url": "https://www.google.com/s2/favicons?domain=wip.co&sz=64", + "description": "WIP is an accountability community where members publish daily shipped progress and organize it into public projects.", + "audience": "Makers, founders, and indie hackers building in public who want to share completed work through a paid accountability community.", + "eligibility": "New members must subscribe, and project content is limited to completed, shipped todos rather than plans.", + "pricing": "paid", + "pricing_note": "New memberships cost $29 per month or $199 per year; older grandfathered free plans are not available to new members.", + "link_type": "dofollow", + "domain_rating": 56, + "monthly_traffic": 1300, + "categories": [ + "community", + "indie", + "startup" + ] + }, + { + "slug": "wired", + "name": "WIRED", + "type": "citation", + "website_url": "https://www.wired.com", + "submission_url": "https://www.wired.com/about/how-to-pitch-stories-to-wired/", + "logo_url": "https://www.google.com/s2/favicons?domain=wired.com&sz=64", + "description": "WIRED is a magazine and website covering how emerging technology, science, and innovation reshape culture, business, and society.", + "audience": "Writers and organizations with a specific, well-reported technology or science story with a broader human, cultural, or business implication.", + "eligibility": "Pitches must offer a narrative story, reporting plan, and original angle on technology or science. Pure product pitches and already-covered topics are not accepted.", + "pricing": "free", + "pricing_note": null, + "link_type": "dofollow", + "domain_rating": 92, + "monthly_traffic": 3400000, + "categories": [ + "business", + "startup" + ] + }, + { + "slug": "yellow-pages", + "name": "Yellow Pages", + "type": "directory", + "website_url": "https://www.yellowpages.com", + "submission_url": "https://www.yellowpages.com/claim-your-listing", + "logo_url": "https://www.google.com/s2/favicons?domain=yellowpages.com&sz=64", + "description": "Yellow Pages is a business directory where owners can claim or create a basic business profile and manage its public details.", + "audience": "Legitimate businesses that can provide and verify accurate name, address, phone, and category information.", + "eligibility": "A business must provide its business details and verify ownership before managing its listing.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 90, + "monthly_traffic": 1200000, + "categories": [ + "business" + ] + }, + { + "slug": "yelp", + "name": "Yelp", + "type": "citation", + "website_url": "https://yelp.com", + "submission_url": "https://biz.yelp.com/claim", + "logo_url": "https://www.google.com/s2/favicons?domain=yelp.com&sz=64", + "description": "Consumer review and local-business discovery platform for nearby services, restaurants, and establishments.", + "audience": "Brick-and-mortar and local service businesses with local or face-to-face interaction; online-only pure software and startups are generally ineligible.", + "eligibility": "Scoped to brick-and-mortar or local service businesses with local or face-to-face interaction. Pure online-only SaaS businesses and startups generally do not qualify.", + "pricing": "free", + "pricing_note": "Free to claim or create a business page; optional advertising products are separate.", + "link_type": "nofollow", + "domain_rating": 94, + "monthly_traffic": 109000000, + "categories": [ + "business" + ] + }, + { + "slug": "zapier-app-directory", + "name": "Zapier App Directory", + "type": "marketplace", + "website_url": "https://zapier.com/apps", + "submission_url": "https://developer.zapier.com/", + "logo_url": "https://www.google.com/s2/favicons?domain=zapier.com&sz=64", + "description": "Zapier is a no-code automation platform whose App Directory lists apps that users can connect in workflows.", + "audience": "Developers of publicly launched apps with an API who want their integration discoverable by Zapier users.", + "eligibility": "A public integration must meet Zapier's technical, quality, ownership, and policy requirements and pass review; private integrations cannot be listed publicly.", + "pricing": "free", + "pricing_note": null, + "link_type": "nofollow", + "domain_rating": 91, + "monthly_traffic": 3600000, + "categories": [ + "productivity", + "saas" + ] + }, + { + "slug": "zoom-app-marketplace", + "name": "Zoom App Marketplace", + "type": "marketplace", + "website_url": "https://marketplace.zoom.us/", + "submission_url": "https://developers.zoom.us/docs/distribute/", + "logo_url": "https://www.google.com/s2/favicons?domain=marketplace.zoom.us&sz=64", + "description": "Zoom's marketplace for apps and integrations that add meeting, team chat, contact-center, phone, event, and workflow capabilities.", + "audience": "Developers with a production Zoom app or integration that provides a real Zoom-connected workflow for customers.", + "eligibility": "A public app must meet Zoom's metadata, functionality, security, privacy, compliance, support, and testing requirements before publication.", + "pricing": "free", + "pricing_note": "Publishing a free app does not carry a stated listing fee. Monetization is optional, requires a free plan, and Zoom charges a 15% commission on Marketplace sales.", + "link_type": "nofollow", + "domain_rating": 94, + "monthly_traffic": 35200, + "categories": [ + "business", + "productivity" + ] + } + ] +} diff --git a/package.json b/package.json index 8a9a727..aa58a62 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@profullstack/submitfleet", - "version": "0.1.0", - "description": "SubmitFleet: one product profile, coordinated launch and directory submissions, proof of what went live. Self-hostable engine with CLI, TUI, HTTP API, MCP server and a mobile dashboard.", + "version": "0.2.0", + "description": "SubmitFleet: launch your product on 339 launch sites and directories from one profile. Prepared submissions, accounts with passkeys, a tracked launch queue, CLI, MCP server and API.", "type": "module", "license": "MIT", "homepage": "https://submitfleet.com", @@ -31,6 +31,7 @@ "files": [ "dist", "catalog", + "web", "README.md", "LICENSE" ], @@ -50,7 +51,9 @@ "@hono/node-server": "2.1.5", "@libsql/client": "0.18.0", "@modelcontextprotocol/sdk": "1.32.1", + "@simplewebauthn/server": "^13.3.3", "hono": "4.13.13", + "undici": "^7.30.0", "zod": "4.6.7" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fab96fe..ed4c953 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,15 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.32.1 version: 1.32.1(zod@4.6.7) + '@simplewebauthn/server': + specifier: ^13.3.3 + version: 13.3.3 hono: specifier: 4.13.13 version: 4.13.13 + undici: + specifier: ^7.30.0 + version: 7.30.0 zod: specifier: 4.6.7 version: 4.6.7 @@ -195,6 +201,9 @@ packages: cpu: [x64] os: [win32] + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@hono/node-server@2.1.5': resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} engines: {node: '>=20'} @@ -211,6 +220,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@libsql/client@0.18.0': resolution: {integrity: sha512-zMCCo58vv7w27j7+cnt/GW+X6/Rih6cHZ86PKi0AxCMOqMfrVgG9OZ5lW2bDMJhSeOjsv2szH2nJF3A9l36UmA==} @@ -284,6 +296,57 @@ packages: '@oxc-project/types@0.153.0': resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + '@peculiar/asn1-android@2.11.0': + resolution: {integrity: sha512-llW/qjavL+tOxt5i9whUv4wsbsLpUwI6k/DrvKsdA/H7A/TB/6CosMFgvei28fcJ8ncD0c5lig1oBBlo4KGMCw==} + engines: {node: '>=14'} + + '@peculiar/asn1-cms@2.11.0': + resolution: {integrity: sha512-SlAiDoT/Cd8qaNmbTt78gqyaEvu2fQd6ML6KWJkdjXGrgDKnHHrusVkfQUelkfinL2p665IIADb0JBixtT8W1g==} + engines: {node: '>=14'} + + '@peculiar/asn1-csr@2.11.0': + resolution: {integrity: sha512-M/ED0yJ05qMK+UADPKnIa8vhAeewOJALSxTAZB7RQ/batRHhIJZkwznCcWAbFO5saHJ1yEQ+m4lncEcG9RnQSg==} + engines: {node: '>=14'} + + '@peculiar/asn1-ecc@2.11.0': + resolution: {integrity: sha512-jj1alGEKev7/un+cI+exyVK9m31lmP7cCBMCZuCb0wkFQH2zmKmJzNiG4wqXFNzlcP8timHLnzf3lV3OxBpb/Q==} + engines: {node: '>=14'} + + '@peculiar/asn1-pfx@2.11.0': + resolution: {integrity: sha512-ykSAWRu8KkmzU8LRpk9ZQC3zuLmBEtoSMjIv9XHVamD5W7amqreV5wttKqnvtjWOzzSJQxA/HVT8/tHwpEEGSQ==} + engines: {node: '>=14'} + + '@peculiar/asn1-pkcs8@2.11.0': + resolution: {integrity: sha512-lragjkjQGnQ5luxC+sxoAfAO/jBiQ7hOEGlDKJUk7MC5zY3AzjB2DU0GyPUH1Sswdv10t7DurY0zylOYMLb88w==} + engines: {node: '>=14'} + + '@peculiar/asn1-pkcs9@2.11.0': + resolution: {integrity: sha512-gDdFjpqV0weJjgNUvueUVf4D8uVbpq+VbG0TsxLAY2QGljAkJyL9qwQ7avol9j0jaYSJ7Ey7v7RZp0M3GTcQSA==} + engines: {node: '>=14'} + + '@peculiar/asn1-rsa@2.11.0': + resolution: {integrity: sha512-S2sBJmbfI09Kzdt9cEhE6pDNjHpVTkURs+811+PxS8AvsTLxC4woMNRhxHYaIbClkkNfAVt0tZxJdINVfUmzuA==} + engines: {node: '>=14'} + + '@peculiar/asn1-schema@2.11.0': + resolution: {integrity: sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509-attr@2.11.0': + resolution: {integrity: sha512-Fgvnd9REmLxEj/4cbDP54dNUNUXkzUksXLqEHqs1mHBuSRjSTCUSPM/WQIZYSvrdG7ND9CX1AQtPoIBekhgQSA==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509@2.11.0': + resolution: {integrity: sha512-J1sHAwGNlk+8J+FeR1eLVc6Gq0T9f9ITc3HWyPYgJraoW6U8AJuQiWVJ3SCEpltfTdpOY7fHdHT5oKAsbK/jww==} + engines: {node: '>=14'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@rolldown/binding-android-arm-eabi@1.2.13': resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -383,6 +446,10 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@simplewebauthn/server@13.3.3': + resolution: {integrity: sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==} + engines: {node: '>=20.0.0'} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -547,6 +614,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -945,6 +1016,13 @@ packages: resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + qs@6.16.0: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} @@ -957,6 +1035,9 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1035,11 +1116,21 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.15: resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} engines: {node: '>=18.0.0'} hasBin: true + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -1052,6 +1143,10 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + undici@7.30.0: + resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} + engines: {node: '>=20.18.1'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -1257,6 +1352,8 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@hexagon/base64@1.1.28': {} + '@hono/node-server@2.1.5(hono@4.13.13)': dependencies: hono: 4.13.13 @@ -1270,6 +1367,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@levischuck/tiny-cbor@0.2.11': {} + '@libsql/client@0.18.0': dependencies: '@libsql/core': 0.18.0 @@ -1354,6 +1453,106 @@ snapshots: '@oxc-project/types@0.153.0': {} + '@peculiar/asn1-android@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + '@peculiar/asn1-x509-attr': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.11.0': + dependencies: + '@peculiar/asn1-cms': 2.11.0 + '@peculiar/asn1-pkcs8': 2.11.0 + '@peculiar/asn1-rsa': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.11.0': + dependencies: + '@peculiar/asn1-cms': 2.11.0 + '@peculiar/asn1-pfx': 2.11.0 + '@peculiar/asn1-pkcs8': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + '@peculiar/asn1-x509-attr': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.11.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.11.0 + '@peculiar/asn1-csr': 2.11.0 + '@peculiar/asn1-ecc': 2.11.0 + '@peculiar/asn1-pkcs9': 2.11.0 + '@peculiar/asn1-rsa': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@rolldown/binding-android-arm-eabi@1.2.13': optional: true @@ -1401,6 +1600,17 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@simplewebauthn/server@13.3.3': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.11.0 + '@peculiar/asn1-ecc': 2.11.0 + '@peculiar/asn1-rsa': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + '@peculiar/x509': 1.14.3 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1505,6 +1715,12 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + assertion-error@2.0.1: {} body-parser@2.3.0: @@ -1885,6 +2101,12 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + qs@6.16.0: dependencies: es-define-property: 1.0.1 @@ -1899,6 +2121,8 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + reflect-metadata@0.2.2: {} + require-from-string@2.0.2: {} rolldown@1.2.13: @@ -2012,12 +2236,20 @@ snapshots: toidentifier@1.0.1: {} + tslib@1.14.1: {} + + tslib@2.8.1: {} + tsx@4.23.15: dependencies: esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + type-is@2.1.0: dependencies: content-type: 2.1.0 @@ -2049,6 +2281,8 @@ snapshots: undici-types@8.9.0: {} + undici@7.30.0: {} + unpipe@1.0.0: {} vary@1.1.2: {} diff --git a/src/assisted.ts b/src/assisted.ts new file mode 100644 index 0000000..efbe96d --- /dev/null +++ b/src/assisted.ts @@ -0,0 +1,99 @@ +import type { Destination } from './catalog.js'; +import type { Product } from './products.js'; + +export interface KitField { + key: string; + label: string; + value: string; + /** Character limit the value already fits, when one is conventional. */ + max?: number; +} + +export interface SubmissionKit { + destination: { + slug: string; + name: string; + submit_url: string | null; + website_url: string | null; + channel: string | null; + pricing: string; + pricing_note: string | null; + eligibility: string | null; + audience: string | null; + link_type: string | null; + domain_rating: number | null; + monthly_traffic: number | null; + }; + fields: KitField[]; + steps: string[]; +} + +/** Cut at a sentence or word boundary so a pasted value never ends mid-word. */ +export function fit(text: string, max: number): string { + const t = text.replace(/\s+/g, ' ').trim(); + if (t.length <= max) return t; + const cut = t.slice(0, max); + const sentence = cut.lastIndexOf('. '); + if (sentence > max * 0.6) return cut.slice(0, sentence + 1); + const word = cut.lastIndexOf(' '); + return `${cut.slice(0, word > 0 ? word : max - 1).replace(/[,;:]$/, '')}…`; +} + +const FACT_LABELS: Record = { + pricing_model: 'Pricing', + launch_date: 'Launch date', + x: 'X / Twitter', + github: 'GitHub', + linkedin: 'LinkedIn', + founder: 'Founder / maker', + company: 'Company', + location: 'Location', +}; + +/** + * Everything a directory form usually asks for, built only from the owner-approved + * product facts. Nothing is invented: a fact that is missing is simply absent. + */ +export function buildKit(product: Product, d: Destination, logoUrl: string | null): SubmissionKit { + const fields: KitField[] = []; + const add = (key: string, label: string, value: string | null | undefined, max?: number) => { + if (value && value.trim()) fields.push({ key, label, value: max ? fit(value, max) : value.trim(), max }); + }; + add('name', 'Product name', product.name); + add('url', 'Website', product.url); + add('tagline', 'Tagline', product.tagline, 60); + add('short_description', 'Short description', product.description ?? product.tagline, 160); + add('description', 'Full description', product.description); + if (product.categories.length) add('categories', 'Categories / tags', product.categories.join(', ')); + add('email', 'Contact email', product.contact_email); + add('logo', 'Logo URL', logoUrl); + for (const [k, v] of Object.entries(product.facts)) { + if (typeof v === 'string' || typeof v === 'number') add(`fact.${k}`, FACT_LABELS[k] ?? k.replace(/_/g, ' '), String(v)); + } + const steps = [ + `Open ${d.submission_url ?? d.website_url ?? d.canonical_domain}${d.account_requirement !== 'none' && d.account_requirement !== 'unknown' ? ' and sign in' : ''}.`, + 'Copy each field below into the matching box. Leave anything they do not ask for.', + d.fee_status === 'source_labeled_free' + ? 'Choose the free option. If the form only offers a paid listing, stop and mark it skipped.' + : 'If the form asks for payment, stop and mark it skipped.', + 'After you submit, come back and mark it submitted (paste the listing or confirmation URL if you got one).', + ]; + return { + destination: { + slug: d.slug, + name: d.name, + submit_url: d.submission_url, + website_url: d.website_url, + channel: d.channel, + pricing: d.fee_status, + pricing_note: d.fee_conditions, + eligibility: d.eligibility, + audience: d.audience, + link_type: d.link_type, + domain_rating: d.domain_rating, + monthly_traffic: d.monthly_traffic, + }, + fields, + steps, + }; +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..517ba6d --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,299 @@ +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, + type AuthenticationResponseJSON, + type AuthenticatorTransportFuture, + type RegistrationResponseJSON, +} from '@simplewebauthn/server'; +import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'; +import { promisify } from 'node:util'; +import { all, one, run, type Db } from './db.js'; +import { newId, nowIso, parseJson, sha256, SubmitFleetError } from './util.js'; +import { createWorkspace, type Actor } from './workspaces.js'; + +const scrypt = promisify(scryptCb) as (pw: string, salt: Buffer, len: number, opts: { N: number; r: number; p: number; maxmem: number }) => Promise; + +export const SESSION_COOKIE = 'sf_session'; +export const SESSION_DAYS = 30; +const LINK_MINUTES = 20; +const MAX_FAILED_LOGINS = 10; +const LOCK_MINUTES = 15; + +export interface User { + id: string; + email: string; + name: string | null; + email_verified_at: string | null; + created_at: string; +} + +const USER_COLS = 'id, email, name, email_verified_at, created_at'; + +export function normalizeEmail(raw: string): string { + const e = raw.trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e) || e.length > 254) throw new SubmitFleetError('invalid_request', 'enter a valid email address'); + return e; +} + +export function checkPasswordStrength(pw: string): void { + if (pw.length < 10) throw new SubmitFleetError('weak_password', 'use at least 10 characters'); + if (pw.length > 200) throw new SubmitFleetError('weak_password', 'use at most 200 characters'); +} + +// ── Passwords: scrypt, self-describing format ── +const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }; + +export async function hashPassword(pw: string): Promise { + const salt = randomBytes(16); + const key = await scrypt(pw.normalize('NFKC'), salt, 32, SCRYPT); + return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('base64url')}$${key.toString('base64url')}`; +} + +export async function verifyPassword(pw: string, stored: string): Promise { + const [alg, n, r, p, salt, key] = stored.split('$'); + if (alg !== 'scrypt' || !salt || !key) return false; + const want = Buffer.from(key, 'base64url'); + const got = await scrypt(pw.normalize('NFKC'), Buffer.from(salt, 'base64url'), want.length, { N: Number(n), r: Number(r), p: Number(p), maxmem: SCRYPT.maxmem }); + return got.length === want.length && timingSafeEqual(got, want); +} + +// A real hash of a random password, so unknown accounts cost the same time as known ones. +let decoy: Promise | null = null; +const decoyHash = () => (decoy ??= hashPassword(randomBytes(16).toString('hex'))); + +// ── Users and workspaces ── + +export async function getUserByEmail(db: Db, email: string): Promise { + return one(db, `SELECT ${USER_COLS} FROM users WHERE email = ?`, email); +} + +export async function getUser(db: Db, id: string): Promise { + return one(db, `SELECT ${USER_COLS} FROM users WHERE id = ?`, id); +} + +/** Creates the user with a workspace they own. The email is verified by the act of using the emailed link. */ +export async function createUser(db: Db, email: string, opts: { name?: string | null; passwordHash?: string | null } = {}): Promise { + const id = newId('usr'); + const ts = nowIso(); + await run(db, 'INSERT INTO users (id, email, name, password_hash, email_verified_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', id, email, opts.name ?? null, opts.passwordHash ?? null, ts, ts, ts); + const ws = await createWorkspace(db, opts.name ? `${opts.name}'s workspace` : email); + await run(db, "INSERT INTO memberships (user_id, workspace_id, role, created_at) VALUES (?, ?, 'owner', ?)", id, ws, ts); + return (await getUser(db, id))!; +} + +export async function userWorkspace(db: Db, userId: string): Promise<{ workspace_id: string; role: string } | null> { + return one(db, 'SELECT workspace_id, role FROM memberships WHERE user_id = ? ORDER BY created_at LIMIT 1', userId); +} + +export async function actorForUser(db: Db, userId: string): Promise { + const m = await userWorkspace(db, userId); + return m ? { workspaceId: m.workspace_id, role: m.role === 'owner' ? 'owner' : 'agent', id: `user:${userId}` } : null; +} + +export async function setPassword(db: Db, userId: string, pw: string): Promise { + checkPasswordStrength(pw); + await run(db, 'UPDATE users SET password_hash = ?, failed_logins = 0, locked_until = NULL, updated_at = ? WHERE id = ?', await hashPassword(pw), nowIso(), userId); +} + +/** + * Password sign-in. Every failure looks the same (no hint whether the address exists), + * unknown addresses still pay for a hash, and ten failures lock password sign-in for a + * while; the emailed link keeps working during a lock. + */ +export async function loginWithPassword(db: Db, emailRaw: string, pw: string, now = Date.now()): Promise { + const email = normalizeEmail(emailRaw); + const row = await one<{ id: string; password_hash: string | null; failed_logins: number; locked_until: string | null }>( + db, + 'SELECT id, password_hash, failed_logins, locked_until FROM users WHERE email = ?', + email, + ); + const fail = new SubmitFleetError('invalid_credentials', 'that email and password do not match'); + if (!row || !row.password_hash) { + await verifyPassword(pw, await decoyHash()); + throw fail; + } + if (row.locked_until && Date.parse(row.locked_until) > now) { + await verifyPassword(pw, await decoyHash()); + throw new SubmitFleetError('locked', 'too many attempts; sign in with an emailed link instead'); + } + if (!(await verifyPassword(pw, row.password_hash))) { + const n = Number(row.failed_logins) + 1; + await run(db, 'UPDATE users SET failed_logins = ?, locked_until = ? WHERE id = ?', n, n >= MAX_FAILED_LOGINS ? new Date(now + LOCK_MINUTES * 60_000).toISOString() : null, row.id); + throw fail; + } + await run(db, 'UPDATE users SET failed_logins = 0, locked_until = NULL WHERE id = ?', row.id); + return (await getUser(db, row.id))!; +} + +// ── Sessions ── + +export async function createSession(db: Db, userId: string, userAgent: string | null, now = Date.now()): Promise<{ token: string; expires: Date }> { + const token = randomBytes(32).toString('base64url'); + const expires = new Date(now + SESSION_DAYS * 86_400_000); + await run(db, 'INSERT INTO sessions (id_hash, user_id, created_at, expires_at, user_agent) VALUES (?, ?, ?, ?, ?)', sha256(token), userId, new Date(now).toISOString(), expires.toISOString(), userAgent?.slice(0, 300) ?? null); + return { token, expires }; +} + +export async function sessionUser(db: Db, token: string | null | undefined, now = Date.now()): Promise { + if (!token) return null; + const r = await one<{ user_id: string; expires_at: string }>(db, 'SELECT user_id, expires_at FROM sessions WHERE id_hash = ?', sha256(token)); + if (!r || Date.parse(r.expires_at) <= now) return null; + return getUser(db, r.user_id); +} + +export async function deleteSession(db: Db, token: string): Promise { + await run(db, 'DELETE FROM sessions WHERE id_hash = ?', sha256(token)); +} + +// ── Emailed links (sign-up confirmation and magic sign-in) ── + +export type LinkPurpose = 'signup' | 'login'; + +export async function createEmailToken(db: Db, email: string, purpose: LinkPurpose, data: Record = {}, now = Date.now()): Promise { + const token = randomBytes(32).toString('base64url'); + await run( + db, + 'INSERT INTO email_tokens (token_hash, email, purpose, data_json, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)', + sha256(token), email, purpose, JSON.stringify(data), new Date(now).toISOString(), new Date(now + LINK_MINUTES * 60_000).toISOString(), + ); + return token; +} + +/** Emails sent to one address in the last window, to cap mail to any single inbox. */ +export async function recentEmailCount(db: Db, email: string, minutes = 15, now = Date.now()): Promise { + const r = await one<{ n: number }>(db, 'SELECT COUNT(*) AS n FROM email_tokens WHERE email = ? AND created_at >= ?', email, new Date(now - minutes * 60_000).toISOString()); + return Number(r?.n ?? 0); +} + +/** + * Spends a link once. A sign-up link creates the account (only now, so nobody can squat + * an address they cannot read); a sign-in link for an unknown address also creates it, + * which makes the emailed link the way in from nothing. + */ +export async function consumeEmailToken(db: Db, token: string, now = Date.now()): Promise<{ user: User; created: boolean; purpose: LinkPurpose }> { + const h = sha256(token); + const ts = new Date(now).toISOString(); + const n = await run(db, 'UPDATE email_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?', ts, h, ts); + if (n !== 1) throw new SubmitFleetError('link_invalid', 'this link has expired or was already used; request a new one'); + const row = (await one<{ email: string; purpose: LinkPurpose; data_json: string }>(db, 'SELECT email, purpose, data_json FROM email_tokens WHERE token_hash = ?', h))!; + const data = parseJson<{ name?: string; password_hash?: string }>(row.data_json, {}); + const existing = await getUserByEmail(db, row.email); + if (existing) { + await run(db, 'UPDATE users SET email_verified_at = COALESCE(email_verified_at, ?), failed_logins = 0, locked_until = NULL WHERE id = ?', ts, existing.id); + return { user: existing, created: false, purpose: row.purpose }; + } + const user = await createUser(db, row.email, { name: data.name ?? null, passwordHash: data.password_hash ?? null }); + return { user, created: true, purpose: row.purpose }; +} + +// ── Passkeys ── + +export interface RelyingParty { + rpID: string; + origin: string; + rpName: string; +} + +export async function passkeyRegistrationOptions(db: Db, rp: RelyingParty, user: User) { + const existing = await all<{ id: string; transports_json: string }>(db, 'SELECT id, transports_json FROM passkeys WHERE user_id = ?', user.id); + const options = await generateRegistrationOptions({ + rpName: rp.rpName, + rpID: rp.rpID, + userName: user.email, + userDisplayName: user.name ?? user.email, + userID: new TextEncoder().encode(user.id), + attestationType: 'none', + excludeCredentials: existing.map((p) => ({ id: p.id, transports: parseJson(p.transports_json, []) })), + authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, + }); + const challengeId = await saveChallenge(db, options.challenge, user.id, 'register'); + return { challenge_id: challengeId, options }; +} + +export async function passkeyRegister(db: Db, rp: RelyingParty, user: User, challengeId: string, response: RegistrationResponseJSON, name?: string): Promise<{ id: string }> { + const challenge = await takeChallenge(db, challengeId, 'register', user.id); + const v = await verifyRegistrationResponse({ response, expectedChallenge: challenge, expectedOrigin: rp.origin, expectedRPID: rp.rpID, requireUserVerification: false }); + if (!v.verified || !v.registrationInfo) throw new SubmitFleetError('passkey_failed', 'the passkey could not be verified'); + const c = v.registrationInfo.credential; + await run( + db, + 'INSERT INTO passkeys (id, user_id, public_key, counter, transports_json, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + c.id, user.id, Buffer.from(c.publicKey).toString('base64url'), c.counter, JSON.stringify(c.transports ?? response.response.transports ?? []), name?.slice(0, 60) ?? 'Passkey', nowIso(), + ); + return { id: c.id }; +} + +export async function passkeyLoginOptions(db: Db, rp: RelyingParty) { + const options = await generateAuthenticationOptions({ rpID: rp.rpID, userVerification: 'preferred' }); + const challengeId = await saveChallenge(db, options.challenge, null, 'login'); + return { challenge_id: challengeId, options }; +} + +export async function passkeyLogin(db: Db, rp: RelyingParty, challengeId: string, response: AuthenticationResponseJSON): Promise { + const challenge = await takeChallenge(db, challengeId, 'login', null); + const pk = await one<{ id: string; user_id: string; public_key: string; counter: number; transports_json: string }>(db, 'SELECT * FROM passkeys WHERE id = ?', response.id); + if (!pk) throw new SubmitFleetError('passkey_failed', 'that passkey is not registered here'); + const v = await verifyAuthenticationResponse({ + response, + expectedChallenge: challenge, + expectedOrigin: rp.origin, + expectedRPID: rp.rpID, + requireUserVerification: false, + credential: { id: pk.id, publicKey: Buffer.from(pk.public_key, 'base64url'), counter: Number(pk.counter), transports: parseJson(pk.transports_json, []) }, + }); + if (!v.verified) throw new SubmitFleetError('passkey_failed', 'the passkey could not be verified'); + await run(db, 'UPDATE passkeys SET counter = ?, last_used_at = ? WHERE id = ?', v.authenticationInfo.newCounter, nowIso(), pk.id); + return (await getUser(db, pk.user_id))!; +} + +export async function listPasskeys(db: Db, userId: string) { + return all(db, 'SELECT id, name, created_at, last_used_at FROM passkeys WHERE user_id = ? ORDER BY created_at', userId); +} + +export async function deletePasskey(db: Db, userId: string, id: string): Promise { + const n = await run(db, 'DELETE FROM passkeys WHERE id = ? AND user_id = ?', id, userId); + if (!n) throw new SubmitFleetError('not_found', 'passkey'); +} + +export async function hasPassword(db: Db, userId: string): Promise { + const r = await one<{ p: string | null }>(db, 'SELECT password_hash AS p FROM users WHERE id = ?', userId); + return !!r?.p; +} + +async function saveChallenge(db: Db, challenge: string, userId: string | null, purpose: string): Promise { + const id = newId('chl'); + await run(db, "DELETE FROM auth_challenges WHERE expires_at < ?", nowIso()); + await run(db, 'INSERT INTO auth_challenges (id, challenge, user_id, purpose, expires_at) VALUES (?, ?, ?, ?, ?)', id, challenge, userId, purpose, new Date(Date.now() + 5 * 60_000).toISOString()); + return id; +} + +async function takeChallenge(db: Db, id: string, purpose: string, userId: string | null): Promise { + const r = await one<{ challenge: string; user_id: string | null; purpose: string; expires_at: string }>(db, 'SELECT * FROM auth_challenges WHERE id = ?', id); + await run(db, 'DELETE FROM auth_challenges WHERE id = ?', id); + if (!r || r.purpose !== purpose || Date.parse(r.expires_at) < Date.now() || (userId && r.user_id !== userId)) { + throw new SubmitFleetError('passkey_failed', 'that request expired; try again'); + } + return r.challenge; +} + +// ── Rate limiting (per process; one app process per site) ── + +const buckets = new Map(); + +/** Returns false once `key` has been seen `limit` times in the window. */ +export function allow(key: string, limit: number, windowMs: number, now = Date.now()): boolean { + const b = buckets.get(key); + if (!b || b.reset <= now) { + buckets.set(key, { n: 1, reset: now + windowMs }); + if (buckets.size > 50_000) for (const [k, v] of buckets) if (v.reset <= now) buckets.delete(k); + return true; + } + b.n++; + return b.n <= limit; +} + +export function resetRateLimits(): void { + buckets.clear(); +} diff --git a/src/campaigns.ts b/src/campaigns.ts index 54efa22..74dfeaa 100644 --- a/src/campaigns.ts +++ b/src/campaigns.ts @@ -1,9 +1,10 @@ import type { Adapter } from './adapters/types.js'; import { createBudget } from './budgets.js'; -import { listDestinations, qualify, RELEASE_MINIMUM, type Destination } from './catalog.js'; +import { assistedBlockers, DEFAULT_CHANNELS, listDestinations, qualify, RELEASE_MINIMUM, type AssistedPolicy, type Destination } from './catalog.js'; import { all, one, run, type Db } from './db.js'; import { requireProduct, type Product } from './products.js'; import { appendEvent } from './ledger.js'; +import { relevance } from './relevance.js'; import { hashValue, newId, nowIso, parseJson, sha256, SubmitFleetError } from './util.js'; import { requireOwner, usableAccount, type Actor } from './workspaces.js'; @@ -25,12 +26,18 @@ export interface PlanInput { releaseScope?: string; /** Restrict to these destination slugs; otherwise every catalog entry is considered. */ destinations?: string[]; + /** 'all' (default) plans assisted targets where no adapter qualifies; 'auto_only' is the strict unattended mode. */ + mode?: 'all' | 'auto_only'; + /** Source channels for assisted targets (default: directory, product-launch-site). */ + channels?: string[]; + includeUnknownPricing?: boolean; } export interface TargetPlan { destination: string; listing_group: string; executable: boolean; + mode: 'auto' | 'assisted' | null; blockers: string[]; account_id: string | null; payload_hash: string | null; @@ -48,6 +55,8 @@ export interface Campaign { earliest_date: string | null; latest_date: string | null; executable_count: number; + mode: 'all' | 'auto_only'; + channels: string[]; created_by: string; created_at: string; updated_at: string; @@ -62,6 +71,8 @@ export interface CampaignPlan { account_ready: number; not_duplicate: number; executable: number; + auto: number; + assisted: number; min_executable: number; }; targets: TargetPlan[]; @@ -80,6 +91,7 @@ function rowToCampaign(r: Record): Campaign { min_executable: Number(r.min_executable), spend_limit_cents: Number(r.spend_limit_cents), executable_count: Number(r.executable_count), + channels: parseJson(r.channels_json, []), }; } @@ -125,12 +137,14 @@ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; /** Builds (or rebuilds) a campaign plan. Re-planning invalidates any prior approval. */ export async function planCampaign(db: Db, actor: Actor, input: PlanInput, deps: PlanDeps, campaignId?: string): Promise { const product = await requireProduct(db, actor.workspaceId, input.productId); - const min = input.minExecutableDestinations ?? RELEASE_MINIMUM; + const mode = input.mode ?? 'all'; + const min = input.minExecutableDestinations ?? (mode === 'auto_only' ? RELEASE_MINIMUM : 1); if (!Number.isInteger(min) || min < 1) throw new SubmitFleetError('invalid_request', 'min_executable_destinations must be a positive integer'); for (const d of [input.earliestDate, input.latestDate]) if (d && !DATE_RE.test(d)) throw new SubmitFleetError('invalid_request', 'dates must be YYYY-MM-DD'); const releaseScope = input.releaseScope ?? 'initial'; const now = deps.now?.() ?? Date.now(); const installed = new Set(deps.adapters.keys()); + const policy: AssistedPolicy = { channels: input.channels ?? DEFAULT_CHANNELS, includeUnknownPricing: !!input.includeUnknownPricing }; let destinations = await listDestinations(db, { limit: 100_000 }); if (input.destinations?.length) { @@ -138,54 +152,70 @@ export async function planCampaign(db: Db, actor: Actor, input: PlanInput, deps: destinations = destinations.filter((d) => want.has(d.slug)); } - const counts = { considered: 0, release_qualifying: 0, product_eligible: 0, account_ready: 0, not_duplicate: 0, executable: 0, min_executable: min }; + const counts = { considered: 0, release_qualifying: 0, product_eligible: 0, account_ready: 0, not_duplicate: 0, executable: 0, auto: 0, assisted: 0, min_executable: min }; const seenGroups = new Set(); const targets: (TargetPlan & { destination_id: string; payload: Record | null })[] = []; for (const d of destinations) { counts.considered++; - const blockers: string[] = []; - const q = qualify(d, { now, installedAdapters: installed }); - blockers.push(...q.blockers); - if (q.stages.release_qualifying) counts.release_qualifying++; + const common: string[] = []; + if (!product.approved_at) common.push('product_facts_unapproved'); + const key = dedupeKey(actor.workspaceId, product, d, releaseScope); + const dup = await one<{ id: string; state: string }>(db, 'SELECT id, state FROM intents WHERE dedupe_key = ? AND dedupe_active = 1', key); + if (dup) common.push(`duplicate_existing_submission:${dup.state}`); + if (seenGroups.has(d.listing_group)) common.push('same_listing_group_as_other_target'); - if (seenGroups.has(d.listing_group)) blockers.push('same_listing_group_as_other_target'); - if (!product.approved_at) blockers.push('product_facts_unapproved'); - if (!productFits(product, d)) blockers.push('category_mismatch'); + // Unattended path: every release-gate condition plus product fit, account and adapter validation. + const auto: string[] = [...qualify(d, { now, installedAdapters: installed }).blockers]; + if (auto.length === 0) counts.release_qualifying++; + if (!productFits(product, d)) auto.push('category_mismatch'); const adapter = d.adapter_id ? deps.adapters.get(d.adapter_id) : undefined; - if (adapter) for (const issue of adapter.validate(product, d)) blockers.push(`${issue.code}:${issue.field}`); - const eligible = blockers.length === 0; - if (eligible) counts.product_eligible++; - + if (adapter) for (const issue of adapter.validate(product, d)) auto.push(`${issue.code}:${issue.field}`); + if (auto.length === 0 && common.length === 0) counts.product_eligible++; const account = await usableAccount(db, actor.workspaceId, d.id, now); - if (!account && d.account_requirement !== 'none') blockers.push('account_not_ready'); - else if (eligible) counts.account_ready++; - - const key = dedupeKey(actor.workspaceId, product, d, releaseScope); - const dup = await one<{ id: string; state: string }>(db, 'SELECT id, state FROM intents WHERE dedupe_key = ? AND dedupe_active = 1', key); - if (dup) blockers.push(`duplicate_existing_submission:${dup.state}`); - else if (eligible && account) counts.not_duplicate++; + if (!account && d.account_requirement !== 'none') auto.push('account_not_ready'); + else if (auto.length === 0 && common.length === 0) counts.account_ready++; + if (!dup && auto.length === 0) counts.not_duplicate++; + let targetMode: 'auto' | 'assisted' | null = null; let payload: Record | null = null; - if (blockers.length === 0 && adapter) { + let blockers: string[]; + if (common.length === 0 && auto.length === 0 && adapter) { try { payload = await adapter.prepare({ product, destination: d, earliestDate: input.earliestDate, latestDate: input.latestDate, readAsset: deps.readAsset }); + targetMode = 'auto'; } catch (e) { - blockers.push(`prepare_failed:${e instanceof SubmitFleetError ? e.code : 'error'}`); + auto.push(`prepare_failed:${e instanceof SubmitFleetError ? e.code : 'error'}`); + } + } + if (!targetMode && mode === 'all') { + const assisted = assistedBlockers(d, policy); + // Without an explicit pick, a niche or platform the product does not match is left out. + if (!input.destinations?.length) { + const r = relevance(product, d); + if (r.fit === 'weak') assisted.push(`not_a_fit:${r.reason}`); + } + if (common.length === 0 && assisted.length === 0) { + targetMode = 'assisted'; + payload = { assisted: true, destination: d.slug }; } + blockers = targetMode ? [] : [...common, ...assisted, ...(assisted.length ? [] : auto.map((a) => `auto:${a}`))]; + } else { + blockers = targetMode ? [] : [...common, ...auto]; } - const executable = blockers.length === 0 && payload !== null; - if (executable) { + if (targetMode) { counts.executable++; + counts[targetMode]++; seenGroups.add(d.listing_group); } targets.push({ destination: d.slug, destination_id: d.id, listing_group: d.listing_group, - executable, + executable: targetMode !== null, + mode: targetMode, blockers, - account_id: account?.id ?? null, + account_id: targetMode === 'auto' ? (account?.id ?? null) : null, payload, payload_hash: payload ? hashValue(payload) : null, }); @@ -194,6 +224,7 @@ export async function planCampaign(db: Db, actor: Actor, input: PlanInput, deps: const status = counts.executable >= min ? 'draft' : 'coverage_insufficient'; const ts = nowIso(); const id = campaignId ?? newId('cmp'); + const channelsJson = JSON.stringify(policy.channels); if (campaignId) { const c = await getCampaign(db, actor.workspaceId, campaignId); if (!['draft', 'coverage_insufficient', 'awaiting_approval', 'approved'].includes(c.status)) { @@ -203,23 +234,23 @@ export async function planCampaign(db: Db, actor: Actor, input: PlanInput, deps: await run(db, 'DELETE FROM campaign_targets WHERE campaign_id = ?', id); await run( db, - 'UPDATE campaigns SET status = ?, min_executable = ?, release_scope = ?, spend_limit_cents = ?, earliest_date = ?, latest_date = ?, executable_count = ?, updated_at = ? WHERE id = ?', - status, min, releaseScope, input.spendLimitCents ?? 0, input.earliestDate, input.latestDate, counts.executable, ts, id, + 'UPDATE campaigns SET status = ?, min_executable = ?, release_scope = ?, spend_limit_cents = ?, earliest_date = ?, latest_date = ?, executable_count = ?, mode = ?, channels_json = ?, include_unknown_pricing = ?, updated_at = ? WHERE id = ?', + status, min, releaseScope, input.spendLimitCents ?? 0, input.earliestDate, input.latestDate, counts.executable, mode, channelsJson, policy.includeUnknownPricing ? 1 : 0, ts, id, ); } else { await run( db, - `INSERT INTO campaigns (id, workspace_id, product_id, status, min_executable, release_scope, policy_json, spend_limit_cents, earliest_date, latest_date, executable_count, created_by, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO campaigns (id, workspace_id, product_id, status, min_executable, release_scope, policy_json, spend_limit_cents, earliest_date, latest_date, executable_count, mode, channels_json, include_unknown_pricing, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, actor.workspaceId, product.id, status, min, releaseScope, JSON.stringify(DEFAULT_POLICY), input.spendLimitCents ?? 0, - input.earliestDate, input.latestDate, counts.executable, actor.id, ts, ts, + input.earliestDate, input.latestDate, counts.executable, mode, channelsJson, policy.includeUnknownPricing ? 1 : 0, actor.id, ts, ts, ); } for (const t of targets) { await run( db, - 'INSERT INTO campaign_targets (id, campaign_id, destination_id, account_id, executable, blockers_json, payload_json, payload_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - newId('tgt'), id, t.destination_id, t.account_id, t.executable ? 1 : 0, JSON.stringify(t.blockers), t.payload ? JSON.stringify(t.payload) : null, t.payload_hash, + 'INSERT INTO campaign_targets (id, campaign_id, destination_id, account_id, executable, blockers_json, payload_json, payload_hash, mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + newId('tgt'), id, t.destination_id, t.account_id, t.executable ? 1 : 0, JSON.stringify(t.blockers), t.payload ? JSON.stringify(t.payload) : null, t.payload_hash, t.mode, ); } return { @@ -239,12 +270,13 @@ interface TargetRow { blockers_json: string; payload_json: string | null; payload_hash: string | null; + mode: 'auto' | 'assisted' | null; } async function targetRows(db: Db, campaignId: string): Promise { return all( db, - `SELECT t.destination_id, d.slug, d.adapter_id, d.adapter_version, t.account_id, t.executable, t.blockers_json, t.payload_json, t.payload_hash + `SELECT t.destination_id, d.slug, d.adapter_id, d.adapter_version, t.account_id, t.executable, t.blockers_json, t.payload_json, t.payload_hash, t.mode FROM campaign_targets t JOIN destinations d ON d.id = t.destination_id WHERE t.campaign_id = ? ORDER BY d.slug`, campaignId, ); @@ -255,7 +287,7 @@ export async function approvalScope(db: Db, c: Campaign): Promise Number(t.executable) === 1) - .map((t) => ({ destination: t.slug, adapter: `${t.adapter_id}@${t.adapter_version}`, account_id: t.account_id, payload_hash: t.payload_hash })); + .map((t) => ({ destination: t.slug, mode: t.mode, adapter: t.mode === 'auto' ? `${t.adapter_id}@${t.adapter_version}` : null, account_id: t.account_id, payload_hash: t.payload_hash })); return { campaign_id: c.id, product: { id: product.id, canonical_key: product.canonical_key, content_hash: productContentHash(product), facts_approved_at: product.approved_at }, @@ -277,7 +309,7 @@ export async function previewCampaign(db: Db, workspaceId: string, id: string) { return { campaign: c, scope_hash: hashValue(scope), - executable: rows.filter((t) => Number(t.executable) === 1).map((t) => ({ destination: t.slug, account_id: t.account_id, payload: parseJson(t.payload_json, {}) })), + executable: rows.filter((t) => Number(t.executable) === 1).map((t) => ({ destination: t.slug, mode: t.mode, account_id: t.account_id, payload: parseJson(t.payload_json, {}) })), blocked: rows.filter((t) => Number(t.executable) !== 1).map((t) => ({ destination: t.slug, blockers: parseJson(t.blockers_json, []) })), }; } @@ -355,11 +387,14 @@ export async function runCampaign(db: Db, actor: Actor, campaignId: string): Pro // Intent is persisted before any external action is attempted. await run( db, - `INSERT INTO intents (id, workspace_id, campaign_id, product_id, destination_id, account_id, approval_id, dedupe_key, state, payload_json, payload_hash, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?)`, - id, actor.workspaceId, campaignId, product.id, t.destination_id, t.account_id, approval.id, key, t.payload_json, t.payload_hash, ts, ts, + `INSERT INTO intents (id, workspace_id, campaign_id, product_id, destination_id, account_id, approval_id, dedupe_key, state, reason_code, mode, payload_json, payload_hash, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, actor.workspaceId, campaignId, product.id, t.destination_id, t.account_id, approval.id, key, + // Assisted work waits for the owner; workers only ever claim 'queued'. + t.mode === 'assisted' ? 'needs_human' : 'queued', t.mode === 'assisted' ? 'assisted' : null, t.mode ?? 'auto', + t.payload_json, t.payload_hash, ts, ts, ); - await appendEvent(db, id, 'queued', { to: 'queued' }); + await appendEvent(db, id, t.mode === 'assisted' ? 'assisted_ready' : 'queued', { to: t.mode === 'assisted' ? 'needs_human' : 'queued' }); queued++; } catch (e) { if (String(e).includes('UNIQUE')) duplicates.push(t.slug); diff --git a/src/catalog.ts b/src/catalog.ts index 0cb8fff..122d391 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -49,6 +49,16 @@ export interface Destination { source: string | null; source_license: string | null; archived: boolean; + /** Raw channel from the source catalog: directory, product-launch-site, citation, community, ... */ + channel: string | null; + description: string | null; + audience: string | null; + eligibility: string | null; + link_type: string | null; + domain_rating: number | null; + monthly_traffic: number | null; + website_url: string | null; + logo_url: string | null; created_at: string; updated_at: string; } @@ -67,6 +77,8 @@ export function rowToDestination(r: Row): Destination { queue_limit_per_account: r.queue_limit_per_account == null ? null : Number(r.queue_limit_per_account), queue_window_hours: r.queue_window_hours == null ? null : Number(r.queue_window_hours), cooldown_hours: r.cooldown_hours == null ? null : Number(r.cooldown_hours), + domain_rating: r.domain_rating == null ? null : Number(r.domain_rating), + monthly_traffic: r.monthly_traffic == null ? null : Number(r.monthly_traffic), }; } @@ -79,6 +91,7 @@ const REVIEWED_FIELDS = [ 'adapter_id', 'adapter_version', 'adapter_health', 'health_reason', 'contract_tested_at', 'live_tested_at', 'live_evidence_ref', 'reconcile_support', 'reconcile_limitation', 'queue_limit_per_account', 'queue_window_hours', 'cooldown_hours', 'disabled', 'source', 'source_license', + 'channel', 'description', 'audience', 'eligibility', 'link_type', 'domain_rating', 'monthly_traffic', 'website_url', 'logo_url', ] as const; // Imports are discovery input only: they may never set anything that enables execution. @@ -159,18 +172,29 @@ export async function getDestination(db: Db, idOrSlug: string): Promise { +export async function listDestinations( + db: Db, + opts: { query?: string; includeArchived?: boolean; limit?: number; offset?: number; channels?: string[]; pricing?: string[] } = {}, +): Promise { const where: string[] = []; const args: unknown[] = []; if (!opts.includeArchived) where.push('archived = 0'); + if (opts.channels?.length) { + where.push(`channel IN (${opts.channels.map(() => '?').join(', ')})`); + args.push(...opts.channels); + } + if (opts.pricing?.length) { + where.push(`fee_status IN (${opts.pricing.map(() => '?').join(', ')})`); + args.push(...opts.pricing); + } if (opts.query) { - where.push('(name LIKE ? OR slug LIKE ? OR canonical_domain LIKE ? OR categories_json LIKE ?)'); + where.push('(name LIKE ? OR slug LIKE ? OR canonical_domain LIKE ? OR categories_json LIKE ? OR description LIKE ?)'); const q = `%${opts.query}%`; - args.push(q, q, q, q); + args.push(q, q, q, q, q); } const rows = await all( db, - `SELECT * FROM destinations ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY slug LIMIT ? OFFSET ?`, + `SELECT * FROM destinations ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY COALESCE(domain_rating, -1) DESC, slug LIMIT ? OFFSET ?`, ...args, opts.limit ?? 500, opts.offset ?? 0, @@ -358,3 +382,147 @@ export async function importDiscovery(db: Db, records: ImportRecord[], source: s } return { created, skipped }; } + +// ── Curated catalog import (awesome-submitlist, CC0) ───────────────────────── + +export interface SubmitlistRecord { + slug: string; + name: string; + type: string; + website_url: string; + submission_url: string | null; + logo_url?: string | null; + description?: string | null; + audience?: string | null; + eligibility?: string | null; + pricing: 'free' | 'paid' | 'unknown' | string; + pricing_note?: string | null; + link_type?: string | null; + domain_rating?: number | null; + monthly_traffic?: number | null; + categories?: string[]; +} + +const KIND_BY_CHANNEL: Record = { directory: 'directory', 'product-launch-site': 'launch', citation: 'directory' }; +const FEE_BY_PRICING: Record = { free: 'source_labeled_free', paid: 'paid', unknown: 'unknown' }; + +/** + * Imports or refreshes the curated catalog. Only descriptive, source-owned fields are + * written; automation, permission and live-test evidence are never touched, so an + * import can make a destination available for assisted submission but never for + * unattended execution. A record whose domain already exists is refreshed in place. + */ +export async function importSubmitlist(db: Db, records: SubmitlistRecord[], source: string): Promise<{ created: number; updated: number; skipped: number }> { + let created = 0; + let updated = 0; + let skipped = 0; + const now = nowIso(); + for (const r of records) { + let domain: string; + let group: string; + try { + const u = new URL(r.website_url); + domain = normalizeDomain(u.hostname); + // Several surfaces can share a host (reddit.com/r/SaaS, reddit.com/r/startups): the + // independent listing group is host + path, which is just the host for most sites. + const path = u.pathname.replace(/\/+$/, '').toLowerCase(); + group = `${domain}${path}`; + } catch { + skipped++; + continue; + } + const fee = FEE_BY_PRICING[r.pricing] ?? 'unknown'; + const fields = { + name: r.name, + channel: r.type, + description: r.description ?? null, + audience: r.audience ?? null, + eligibility: r.eligibility ?? null, + link_type: r.link_type ?? null, + domain_rating: r.domain_rating ?? null, + monthly_traffic: r.monthly_traffic ?? null, + website_url: r.website_url, + logo_url: r.logo_url ?? null, + submission_url: r.submission_url ?? r.website_url, + categories_json: JSON.stringify(r.categories ?? []), + fee_conditions: r.pricing_note ?? null, + source, + source_license: 'CC0-1.0', + }; + const existing = await one<{ id: string; fee_status: string }>(db, 'SELECT id, fee_status FROM destinations WHERE slug = ? OR listing_group = ? ORDER BY slug = ? DESC LIMIT 1', r.slug, group, r.slug); + if (existing) { + const cols = Object.keys(fields); + // A reviewed verified_free status is ours; the source label never downgrades or overrides it. + const feeSql = existing.fee_status === 'verified_free' ? '' : ', fee_status = ?'; + await run( + db, + `UPDATE destinations SET ${cols.map((c) => `${c} = ?`).join(', ')}${feeSql}, updated_at = ? WHERE id = ?`, + ...Object.values(fields), + ...(feeSql ? [fee] : []), + now, + existing.id, + ); + updated++; + } else { + const all = { ...fields, kind: KIND_BY_CHANNEL[r.type] ?? 'other', fee_status: fee, canonical_domain: domain, listing_group: group }; + const names = ['id', 'slug', ...Object.keys(all), 'created_at', 'updated_at']; + await run( + db, + `INSERT INTO destinations (${names.join(', ')}) VALUES (${names.map(() => '?').join(', ')})`, + newId('dst'), + r.slug, + ...Object.values(all), + now, + now, + ); + created++; + } + } + return { created, updated, skipped }; +} + +// ── Assisted submission eligibility ────────────────────────────────────────── + +export const DEFAULT_CHANNELS = ['directory', 'product-launch-site']; + +export interface AssistedPolicy { + channels: string[]; + includeUnknownPricing: boolean; +} + +/** Blockers for an owner-performed (assisted) submission: free routes with a submit page. */ +export function assistedBlockers(d: Destination, policy: AssistedPolicy): string[] { + const b: string[] = []; + if (d.archived) b.push('archived'); + if (d.disabled) b.push('disabled'); + if (!d.submission_url) b.push('no_submission_url'); + if (d.fee_status === 'paid') b.push('paid_route'); + else if (d.fee_status === 'conditional_free') b.push('conditional_free'); + else if (d.fee_status === 'unknown' && !policy.includeUnknownPricing) b.push('pricing_unknown'); + if (d.backlink_required) b.push('backlink_required'); + if (d.quality_decision === 'rejected') b.push('quality_rejected'); + const channel = d.channel ?? (d.kind === 'launch' ? 'product-launch-site' : d.kind); + if (policy.channels.length && !policy.channels.includes(channel)) b.push(`channel_not_selected:${channel}`); + return b; +} + +export interface CatalogStats { + total: number; + free: number; + by_channel: Record; + automated: number; +} + +export async function catalogStats(db: Db, installed: Set): Promise { + const ds = await listDestinations(db, { limit: 100_000 }); + const by: Record = {}; + let free = 0; + let automated = 0; + for (const d of ds) { + const c = d.channel ?? d.kind; + by[c] = (by[c] ?? 0) + 1; + if (d.fee_status === 'source_labeled_free' || d.fee_status === 'verified_free') free++; + if (qualify(d, { installedAdapters: installed }).stages.release_qualifying) automated++; + } + return { total: ds.length, free, by_channel: by, automated }; +} diff --git a/src/cli.ts b/src/cli.ts index c1892dc..7d9fd41 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,8 @@ import { buildApp } from './http.js'; import { buildMcpServer } from './mcp.js'; import { SubmitFleet } from './service.js'; import { runTui } from './tui.js'; +import { resendMailer } from './mail.js'; +import { accessToken, DEFAULT_URL, login, logout, mcpStdioProxy, readAuth, remote } from './remote.js'; import { SubmitFleetError } from './util.js'; import { createApiKey, ensureDefaultWorkspace, type Actor, type Role } from './workspaces.js'; @@ -21,7 +23,12 @@ const HELP = `submitfleet - coordinated launch and directory submissions with pr Usage: submitfleet [options] -Setup +Account (hosted at submitfleet.com, or --url for your own server) + login [--url URL] [--no-browser] sign in through your browser (OAuth 2.1 + PKCE); later commands use your account + logout | whoami + --local ignore the signed-in account and use the local database + +Setup (self-hosted) init create the local workspace, secret key and an owner API key doctor non-mutating health check keys create --role owner|agent [--label L] @@ -44,8 +51,10 @@ Accounts accounts list | accounts disconnect ID Campaigns - campaign plan --product ID [--free-only] [--min-executable-destinations 100] [--spend-limit-cents 0] - [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--dest SLUG]... + campaign plan --product ID [--free-only] [--auto-only] [--min-executable-destinations N] [--spend-limit-cents 0] + [--channel directory|product-launch-site|citation|community|newsletter|subreddit|marketplace]... + [--include-unknown-pricing] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--dest SLUG]... + (assisted targets are included unless --auto-only; --auto-only defaults to a 100 minimum) campaign list | preview ID | approve ID | run ID | status ID [--json] | pause ID | cancel ID pause-all | resume queue jobs waiting for a person @@ -122,6 +131,9 @@ export async function main(argv = process.argv.slice(2)): Promise { email: { type: 'string' }, product: { type: 'string' }, 'free-only': { type: 'boolean' }, + 'auto-only': { type: 'boolean' }, + channel: { type: 'string', multiple: true }, + 'include-unknown-pricing': { type: 'boolean' }, 'min-executable-destinations': { type: 'string' }, 'spend-limit-cents': { type: 'string' }, from: { type: 'string' }, @@ -137,6 +149,9 @@ export async function main(argv = process.argv.slice(2)): Promise { 'no-worker': { type: 'boolean' }, stdio: { type: 'boolean' }, 'dry-run': { type: 'boolean' }, + local: { type: 'boolean' }, + url: { type: 'string' }, + 'no-browser': { type: 'boolean' }, }, }); const [cmd, sub, arg] = pos; @@ -149,6 +164,28 @@ export async function main(argv = process.argv.slice(2)): Promise { return EXIT.ok; } + // Account commands, and the hosted account whenever the CLI is signed in. + if (cmd === 'login') { + try { + const a = await login(String(o.url ?? process.env.SUBMITFLEET_URL ?? DEFAULT_URL), log, { open: !o['no-browser'] }); + const who = (await remote('GET', '/api/me')) as { user?: { email?: string } }; + out({ signed_in: true, url: a.url, email: who.user?.email ?? null }); + return EXIT.ok; + } catch (e) { + out({ error: e instanceof SubmitFleetError ? e.code : 'login_failed', message: e instanceof Error ? e.message : String(e) }); + return EXIT.error; + } + } + if (cmd === 'logout') return out({ signed_out: await logout() }), EXIT.ok; + const remoteMode = !!readAuth() && !o.local && !process.env.SUBMITFLEET_LOCAL; + if (remoteMode) { + const r = await runRemote(cmd, sub, arg, o); + if (r !== null) return r; + } else if (cmd === 'whoami') { + log('Not signed in: run submitfleet login (this CLI is using its local database).'); + return EXIT.error; + } + const fleet = await SubmitFleet.open(loadConfig()); const ws = await ensureDefaultWorkspace(fleet.db); // Local CLI access implies filesystem access to the database: it acts as the owner. @@ -177,7 +214,7 @@ export async function main(argv = process.argv.slice(2)): Promise { return EXIT.ok; } case 'catalog': { - if (sub === 'seed') return out({ seeded: await seedCatalog(fleet, owner, false) }), EXIT.ok; + if (sub === 'seed') return out(await fleet.loadBundledCatalog()), EXIT.ok; if (sub === 'search') { const ds = await fleet.catalogSearch(arg); out(o.json ? ds : ds.map((d) => `${d.slug}\t${d.kind}\t${d.fee_status}\t${d.automation_method}\t${d.adapter_health}`).join('\n') || 'no destinations'); @@ -263,6 +300,9 @@ export async function main(argv = process.argv.slice(2)): Promise { earliestDate: o.from as string | undefined, latestDate: o.to as string | undefined, destinations: o.dest as string[] | undefined, + mode: o['auto-only'] ? 'auto_only' : 'all', + channels: o.channel as string[] | undefined, + includeUnknownPricing: !!o['include-unknown-pricing'], }); out(o.json ? plan : { campaign: plan.campaign.id, status: plan.campaign.status, counts: plan.counts, blocked: plan.targets.filter((t) => !t.executable).slice(0, 50) }); return plan.campaign.status === 'coverage_insufficient' ? EXIT.gate : EXIT.ok; @@ -325,10 +365,15 @@ export async function main(argv = process.argv.slice(2)): Promise { return EXIT.ok; } case 'serve': { - const added = await seedCatalog(fleet, owner, true); - if (added) log(`seeded ${added} missing catalog record(s)`); + const loaded = await fleet.loadBundledCatalog(); + log(`catalog: ${loaded.created} added, ${loaded.updated} refreshed, ${loaded.seeded} reviewed records applied`); const port = num(o.port, Number(process.env.PORT ?? 8787))!; - const app = buildApp(fleet, { allowedOrigins: (process.env.SUBMITFLEET_ALLOWED_ORIGINS ?? '').split(',').filter(Boolean) }); + const app = buildApp(fleet, { + allowedOrigins: (process.env.SUBMITFLEET_ALLOWED_ORIGINS ?? '').split(',').filter(Boolean), + mailer: resendMailer(process.env.RESEND_API_KEY, process.env.SUBMITFLEET_MAIL_FROM ?? 'SubmitFleet '), + publicOrigin: process.env.SUBMITFLEET_PUBLIC_ORIGIN, + }); + if (!process.env.RESEND_API_KEY) log('RESEND_API_KEY is not set: sign-in emails are printed here instead of sent'); serve({ fetch: app.fetch, port, hostname: process.env.HOST ?? '127.0.0.1' }); log(`submitfleet listening on http://${process.env.HOST ?? '127.0.0.1'}:${port}`); if (!o['no-worker']) await loop(fleet.worker(), Number(process.env.SUBMITFLEET_WORKER_INTERVAL_MS ?? 5000)); @@ -366,18 +411,6 @@ export async function main(argv = process.argv.slice(2)): Promise { } } -/** Loads the reviewed seed records. With onlyMissing, never overwrites a record edited since. */ -async function seedCatalog(fleet: SubmitFleet, owner: Actor, onlyMissing: boolean): Promise { - const seed = JSON.parse(readFileSync(join(packageRoot(), 'catalog', 'seed.json'), 'utf8')) as { destinations: DestinationInput[] }; - let n = 0; - for (const d of seed.destinations) { - if (onlyMissing && (await fleet.catalogSearch(d.slug)).some((x) => x.slug === d.slug)) continue; - await fleet.catalogUpsert(owner, d); - n++; - } - return n; -} - async function loop(w: ReturnType, intervalMs: number): Promise { let stop = false; for (const sig of ['SIGINT', 'SIGTERM'] as const) process.once(sig, () => (stop = true)); @@ -406,3 +439,79 @@ if (invokedDirectly()) { process.exitCode = code; }); } + +/** Commands served by the hosted account. Returns null for commands that only make sense locally. */ +async function runRemote(cmd: string, sub: string | undefined, arg: string | undefined, o: Record): Promise { + try { + const show = (v: unknown) => (out(v), EXIT.ok); + switch (cmd) { + case 'whoami': { + const a = await accessToken(); + const me = (await remote('GET', '/api/me')) as { user?: { email?: string }; role: string; workspace_id: string }; + return show({ url: a.url, email: me.user?.email ?? null, role: me.role, workspace_id: me.workspace_id }); + } + case 'products': + if (sub === 'list') return show(await remote('GET', '/products')); + if (sub === 'get' && arg) return show(await remote('GET', `/products/${encodeURIComponent(arg)}`)); + if (sub === 'add' && arg) { + const facts = (await remote('POST', '/products/extract', { url: arg }).catch(() => ({}))) as Record; + return show(await remote('POST', '/products', { + url: arg, + name: (o.name as string) ?? facts.name ?? null, + tagline: (o.tagline as string) ?? facts.tagline ?? null, + description: (o.description as string) ?? facts.description ?? null, + categories: (o.category as string[]) ?? [], + })); + } + return null; + case 'catalog': + if (sub === 'search') return show(await remote('GET', `/catalog?limit=500${arg ? `&q=${encodeURIComponent(arg)}` : ''}`)); + if (sub === 'get' && arg) return show(await remote('GET', `/catalog/${encodeURIComponent(arg)}`)); + return null; + case 'coverage': + return show(await remote('GET', '/coverage')); + case 'campaign': + if (sub === 'list') return show(await remote('GET', '/campaigns')); + if (sub === 'plan') { + if (!o.product) return null; + return show(await remote('POST', '/campaigns', { + product_id: o.product, + min_executable_destinations: o['min-executable-destinations'] ? Number(o['min-executable-destinations']) : undefined, + mode: o['auto-only'] ? 'auto_only' : 'all', + channels: o.channel, + include_unknown_pricing: !!o['include-unknown-pricing'], + destinations: o.dest, + })); + } + if (!arg) return null; + if (sub === 'status') return show(await remote('GET', `/campaigns/${arg}/status`)); + if (sub === 'preview') return show(await remote('GET', `/campaigns/${arg}/preview`)); + if (sub === 'queue') return show(await remote('GET', `/campaigns/${arg}/queue`)); + if (sub === 'run') return show(await remote('POST', `/campaigns/${arg}/run`)); + if (sub === 'pause') return show(await remote('POST', `/campaigns/${arg}/pause`)); + if (sub === 'cancel') return show(await remote('POST', `/campaigns/${arg}/cancel`)); + if (sub === 'approve') { + log('Approve campaigns in the web app: the CLI only requests approval.'); + return show(await remote('POST', `/campaigns/${arg}/approval-request`)); + } + return null; + case 'queue': + return show(await remote('GET', '/human-queue')); + case 'submission': + if (sub === 'get' && arg) return show(await remote('GET', `/submissions/${arg}`)); + if (sub === 'kit' && arg) return show(await remote('GET', `/submissions/${arg}/kit`)); + return null; + case 'report': + if (sub === 'export' && arg) return show(await remote('GET', `/reports/${arg}?format=${String(o.format ?? 'markdown')}`)); + return null; + case 'mcp': + await mcpStdioProxy(); + return EXIT.ok; + default: + return null; + } + } catch (e) { + out({ error: e instanceof SubmitFleetError ? e.code : 'error', message: e instanceof Error ? e.message : String(e), detail: e instanceof SubmitFleetError ? (e.detail ?? null) : null }); + return EXIT.error; + } +} diff --git a/src/dashboard.ts b/src/dashboard.ts deleted file mode 100644 index b8759b4..0000000 --- a/src/dashboard.ts +++ /dev/null @@ -1,203 +0,0 @@ -// Mobile-first dashboard. Plain HTML + one same-origin script, so the CSP can forbid -// inline script. The browser only talks to /api/v1 with the user's bearer key. - -export const LANDING_HTML = ` - -SubmitFleet - -
-

SubmitFleet

-

One product profile. Coordinated submissions. Proof of what went live.

-

An open-source, self-hostable engine for launch-site and directory submissions: maintained adapters, owner-approved campaigns, durable workers, receipts and verified listings. Free listings only by default.

-

Status: early engine. Destination coverage is reported honestly by submitfleet coverage report; it is below the 100-destination release gate today.

-

Dashboard · Source

-
`; - -export const DASHBOARD_HTML = ` - -SubmitFleet Dashboard - -

SubmitFleet

- -
- -
-
-
- -`; - -export const DASHBOARD_JS = String.raw`(() => { - const $ = (s) => document.querySelector(s); - let key = null; - try { key = sessionStorage.getItem('sf_key'); } catch {} - const toast = (msg) => { const t = $('#toast'); t.textContent = msg; t.style.display = 'block'; clearTimeout(t._h); t._h = setTimeout(() => (t.style.display = 'none'), 3500); }; - const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); - async function api(path, opts = {}) { - const res = await fetch('/api/v1' + path, { ...opts, headers: { authorization: 'Bearer ' + key, 'content-type': 'application/json', ...(opts.headers || {}) } }); - const type = res.headers.get('content-type') || ''; - const data = type.includes('json') ? await res.json() : await res.text(); - if (res.status === 401) { showLogin(); throw new Error('unauthorized'); } - if (!res.ok) throw new Error((data && data.error ? data.error + ': ' + (data.message || '') : 'HTTP ' + res.status)); - return data; - } - function showLogin() { $('#login').hidden = false; $('#view').innerHTML = ''; } - $('#saveKey').onclick = () => { key = $('#key').value.trim(); try { sessionStorage.setItem('sf_key', key); } catch {} $('#login').hidden = true; boot(); }; - - async function copyText(text, fallbackHost) { - try { - if (!navigator.clipboard || !window.isSecureContext) throw new Error('no clipboard'); - await navigator.clipboard.writeText(text); - toast('Copied Markdown'); - } catch { - fallbackHost.innerHTML = '

Clipboard is unavailable here. The text below is selected: copy it manually.

'; - const ta = fallbackHost.querySelector('textarea'); ta.value = text; ta.focus(); ta.select(); - } - } - async function download(path, name) { - const res = await fetch('/api/v1' + path, { headers: { authorization: 'Bearer ' + key } }); - if (!res.ok) return toast('Download failed: HTTP ' + res.status); - const url = URL.createObjectURL(await res.blob()); - const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); - } - - const views = { - async coverage(v) { - const c = await api('/coverage'); - const stages = ['discovered', 'source_labeled_free', 'verified_free', 'permission_reviewed', 'live_tested', 'release_qualifying']; - const blockers = Object.entries(c.blocker_counts).sort((a, b) => b[1] - a[1]).slice(0, 12); - v.innerHTML = '

Engine coverage

' + c.release_qualifying + ' of ' + c.release_minimum + ' required qualifying destinations (' + esc(c.status) + ')

' + - stages.map((s) => '
' + c[s] + '' + s.replace(/_/g, ' ') + '
').join('') + '
' + - '

Top blockers

' + blockers.map(([b, n]) => '').join('') + '
' + esc(b) + '' + n + '
'; - }, - async products(v) { - const ps = await api('/products'); - v.innerHTML = '

Portfolio

' + (ps.length ? '' : '

No products yet. Add one with submitfleet products add URL.

') + - ps.map((p) => '
' + esc(p.name || p.url) + '
' + esc(p.url) + '
' + (p.approved_at ? 'facts approved' : 'facts not approved') + '
' + esc(p.id) + '
').join('') + '
'; - }, - async campaigns(v) { - const cs = await api('/campaigns'); - v.innerHTML = '

Campaigns

' + (cs.length ? '' : '

No campaigns yet.

') + cs.map((c) => - '
' + esc(c.id) + '' + esc(c.status) + '
' + - '

' + c.executable_count + ' executable of ' + c.min_executable + ' required · spend limit $' + (c.spend_limit_cents / 100).toFixed(2) + ' · listing fees $0.00

' + - '
').join(''); - v.querySelectorAll('[data-act]').forEach((b) => (b.onclick = () => act(b.closest('[data-id]').dataset.id, b.dataset.act, b.closest('.card').querySelector('.out')))); - }, - async queue(v) { - const q = await api('/human-queue'); - v.innerHTML = '

Human actions

' + (q.length ? '
' + - q.map((i) => '').join('') + '
DestinationStateReason
' + esc(i.destination) + '' + esc(i.state) + '' + esc(i.reason_code) + '' + (i.state === 'needs_human' ? '' : '') + '
' : '

Nothing is waiting for you.

') + - '

Some steps (captchas, payments, logins) need a desktop browser at the destination; they cannot be done from here.

'; - v.querySelectorAll('[data-requeue]').forEach((b) => (b.onclick = async () => { await api('/submissions/' + b.dataset.requeue + '/requeue', { method: 'POST' }); toast('Requeued'); views.queue(v); })); - }, - async accounts(v) { - const as = await api('/accounts'); - v.innerHTML = '

Accounts

' + (as.length ? '' + as.map((a) => '').join('') + '
' + esc(a.label) + '' + esc(a.status) + '' + esc(a.expires_at || '') + '
' : '

No connected accounts.

') + - '

Connect accounts with submitfleet accounts connect DESTINATION --email you@example.com. Codes and passwords never pass through an agent.

'; - }, - }; - - async function act(id, what, out) { - try { - if (what === 'preview') { const p = await api('/campaigns/' + id + '/preview'); out.innerHTML = '

' + p.executable.length + ' executable, ' + p.blocked.length + ' blocked. Scope ' + esc(p.scope_hash.slice(0, 12)) + '

' + esc(JSON.stringify(p.executable.map((e) => e.destination), null, 1)) + '
'; } - if (what === 'approve') { - const p = await api('/campaigns/' + id + '/preview'); - if (!confirm('Approve ' + p.executable.length + ' free submissions for this campaign?\nListing spend: $0. Operating limit: $' + (p.campaign.spend_limit_cents / 100).toFixed(2) + '\nScope ' + p.scope_hash.slice(0, 12))) return; - await api('/campaigns/' + id + '/approve', { method: 'POST', body: JSON.stringify({ scope_hash: p.scope_hash }) }); toast('Approved'); - } - if (what === 'run') { const r = await api('/campaigns/' + id + '/run', { method: 'POST' }); toast('Queued ' + r.queued); } - if (what === 'status') { const s = await api('/campaigns/' + id + '/status'); out.innerHTML = '
' + esc(JSON.stringify(s.intents, null, 1)) + '
'; } - if (what === 'pause') { await api('/campaigns/' + id + '/pause', { method: 'POST' }); toast('Paused'); } - if (what === 'cancel') { if (!confirm('Cancel unsent work? Listings that already exist stay live.')) return; const r = await api('/campaigns/' + id + '/cancel', { method: 'POST' }); toast('Canceled ' + r.canceled + '; ' + r.already_external + ' already sent'); } - if (what === 'report') { - const md = await api('/reports/' + id + '?format=markdown'); - out.innerHTML = '
';
-        out.querySelector('pre').textContent = md;
-        out.querySelector('[data-copy]').onclick = () => copyText(md, out.querySelector('.fallback'));
-        out.querySelectorAll('[data-dl]').forEach((b) => (b.onclick = () => download('/reports/' + id + '?format=' + b.dataset.dl, id + '.' + (b.dataset.dl === 'markdown' ? 'md' : b.dataset.dl))));
-      }
-    } catch (e) { toast(String(e.message || e)); }
-  }
-
-  let current = 'coverage';
-  document.querySelectorAll('#tabs button').forEach((b) => (b.onclick = () => { current = b.dataset.tab; document.querySelectorAll('#tabs button').forEach((x) => x.setAttribute('aria-selected', String(x === b))); render(); }));
-  $('#pauseAll').onclick = async () => { if (!confirm('Stop all new outbound writes now?')) return; await api('/pause', { method: 'POST' }); toast('All outbound writes paused'); };
-  async function render() { try { await views[current]($('#view')); } catch (e) { if (String(e.message) !== 'unauthorized') $('#view').innerHTML = '

' + esc(e.message) + '

'; } } - async function boot() { - if (!key) return showLogin(); - try { const w = await api('/whoami'); $('#who').textContent = w.role; render(); } catch {} - } - boot(); -})(); -`; - -export const INSTALL_SH = `#!/bin/sh -# SubmitFleet CLI installer: curl -fsSL https://submitfleet.com/install.sh | sh -set -eu -if ! command -v node >/dev/null 2>&1; then - echo "submitfleet needs Node.js 22.5 or newer: https://nodejs.org/" >&2 - exit 1 -fi -major=$(node -p 'process.versions.node.split(".")[0]') -if [ "$major" -lt 22 ]; then - echo "submitfleet needs Node.js 22.5 or newer (found $(node -v))" >&2 - exit 1 -fi -# npm first: its global bin works out of the box. pnpm only when npm is missing or fails -# (pnpm add -g aborts unless its global bin directory was set up beforehand). -if command -v npm >/dev/null 2>&1 && npm install -g @profullstack/submitfleet; then - bin_dir="$(npm prefix -g)/bin" -elif command -v pnpm >/dev/null 2>&1 && pnpm add -g @profullstack/submitfleet; then - bin_dir="$(pnpm bin -g)" -else - echo "submitfleet: install failed (tried npm and pnpm)" >&2 - exit 1 -fi -if command -v submitfleet >/dev/null 2>&1; then - submitfleet doctor || true - echo "Installed. Try: submitfleet coverage report" -else - echo "Installed to $bin_dir, which is not on your PATH. Add it, then run: submitfleet doctor" -fi -`; diff --git a/src/extract.ts b/src/extract.ts new file mode 100644 index 0000000..5a64807 --- /dev/null +++ b/src/extract.ts @@ -0,0 +1,176 @@ +import { lookup as dnsLookup } from 'node:dns'; +import { lookup } from 'node:dns/promises'; +import { Agent, fetch as undiciFetch } from 'undici'; +import { isIP } from 'node:net'; +import { SubmitFleetError } from './util.js'; + +const MAX_BYTES = 1_000_000; +const TIMEOUT_MS = 8000; +const MAX_REDIRECTS = 3; + +/** True for loopback, private, link-local, CGNAT, multicast and other non-public ranges. */ +export function isPrivateAddress(ip: string): boolean { + if (isIP(ip) === 4) { + const [a, b] = ip.split('.').map(Number) as [number, number]; + return ( + a === 0 || a === 10 || a === 127 || a >= 224 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 192 && b === 0) || + (a === 198 && (b === 18 || b === 19)) + ); + } + const v = ip.toLowerCase(); + if (v.startsWith('::ffff:')) return isPrivateAddress(v.slice(7)); + return v === '::' || v === '::1' || v.startsWith('fc') || v.startsWith('fd') || v.startsWith('fe8') || v.startsWith('fe9') || v.startsWith('fea') || v.startsWith('feb') || v.startsWith('ff'); +} + +async function assertPublic(url: URL): Promise { + if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new SubmitFleetError('invalid_url_scheme', 'only http(s) URLs can be read'); + if (url.port && !['80', '443'].includes(url.port)) throw new SubmitFleetError('url_not_public', 'non-standard ports are not fetched'); + const host = url.hostname.replace(/^\[|\]$/g, ''); + const addrs = isIP(host) ? [{ address: host }] : await lookup(host, { all: true }).catch(() => []); + if (!addrs.length) throw new SubmitFleetError('url_unresolvable', `cannot resolve ${host}`); + if (addrs.some((a) => isPrivateAddress(a.address))) throw new SubmitFleetError('url_not_public', 'that address is not on the public internet'); +} + +// The connection itself re-checks every resolved address, so a name that resolves +// publicly for the pre-check and privately at connect time (DNS rebinding) still fails. +const guardedAgent = new Agent({ + connect: { + lookup: (host, opts, cb) => + dnsLookup(host, { ...opts, all: true }, (err, addrs) => { + if (err) return cb(err, '', 4); + const list = addrs as unknown as { address: string; family: number }[]; + if (list.some((a) => isPrivateAddress(a.address))) return cb(new Error('url_not_public'), '', 4); + if ((opts as { all?: boolean }).all) return (cb as unknown as (e: null, a: typeof list) => void)(null, list); + cb(null, list[0]!.address, list[0]!.family); + }), + }, +}); + +/** fetch() whose connections go through the address-checking agent. */ +export const guardedFetch = ((input: string | URL, init?: RequestInit) => + undiciFetch(input, { ...(init as object), dispatcher: guardedAgent } as Parameters[1])) as unknown as typeof fetch; + +/** Fetches a public page with SSRF guards: DNS checked at every hop, no private ranges, bounded size and time. */ +export async function fetchPublic(raw: string, f: typeof fetch = guardedFetch, accept = 'text/html'): Promise<{ url: string; body: Buffer; type: string }> { + let url = new URL(raw); + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + await assertPublic(url); + const res = await f(url, { redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT_MS), headers: { accept, 'user-agent': 'SubmitFleet/0.2 (+https://submitfleet.com)' } }); + if (res.status >= 300 && res.status < 400 && res.headers.get('location')) { + url = new URL(res.headers.get('location')!, url); + continue; + } + if (!res.ok) throw new SubmitFleetError('fetch_failed', `HTTP ${res.status}`); + const reader = res.body?.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (reader) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > MAX_BYTES) { + await reader.cancel(); + break; + } + chunks.push(value); + } + return { url: url.toString(), body: Buffer.concat(chunks), type: res.headers.get('content-type') ?? '' }; + } + throw new SubmitFleetError('fetch_failed', 'too many redirects'); +} + +function decodeEntities(s: string): string { + return s + .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))) + .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))) + .replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ').replace(/&/g, '&'); +} + +function attr(tag: string, name: string): string | null { + const m = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag); + return m ? decodeEntities(m[2] ?? m[3] ?? m[4] ?? '').trim() : null; +} + +export interface ProposedFacts { + url: string; + name: string | null; + tagline: string | null; + description: string | null; + icon_url: string | null; + x: string | null; + github: string | null; +} + +/** Reads what a page says about itself. Every value is a proposal for the owner to edit and approve. */ +export function parseFacts(html: string, pageUrl: string): ProposedFacts { + const head = html.slice(0, 300_000); + const metas = head.match(/]*>/gi) ?? []; + const meta = (key: string) => { + for (const m of metas) { + const k = (attr(m, 'property') ?? attr(m, 'name') ?? '').toLowerCase(); + if (k === key) return attr(m, 'content'); + } + return null; + }; + const title = (() => { + const m = /]*>([\s\S]*?)<\/title>/i.exec(head); + return m ? decodeEntities(m[1]!.replace(/\s+/g, ' ')).trim() : null; + })(); + const siteName = meta('og:site_name') ?? meta('application-name'); + const ogTitle = meta('og:title') ?? meta('twitter:title'); + let name = siteName ?? null; + let tagline: string | null = null; + const t = ogTitle ?? title; + if (t) { + const parts = t.split(/\s+[|\-–—:·]\s+/); + if (!name) name = parts[0] ?? t; + const rest = parts.filter((p) => p !== name); + if (rest.length) tagline = rest.join(' - '); + } + const description = meta('og:description') ?? meta('description') ?? meta('twitter:description'); + if (!tagline && description && description.length <= 80) tagline = description; + const links = head.match(/]*>/gi) ?? []; + const icon = (() => { + for (const want of ['apple-touch-icon', 'icon', 'shortcut icon']) { + for (const l of links) { + if ((attr(l, 'rel') ?? '').toLowerCase().split(/\s+/).join(' ') === want && attr(l, 'href')) { + try { + return new URL(attr(l, 'href')!, pageUrl).toString(); + } catch { + /* ignore malformed href */ + } + } + } + } + return null; + })(); + const anchors = head.match(/href\s*=\s*["']([^"']+)["']/gi) ?? []; + const find = (re: RegExp) => { + for (const a of anchors) { + const m = re.exec(a); + if (m) return m[1]!; + } + return null; + }; + const xHandle = meta('twitter:site') ?? meta('twitter:creator') ?? (find(/https?:\/\/(?:www\.)?(?:x|twitter)\.com\/([A-Za-z0-9_]{1,15})(?:["'/?])/i) ? `@${find(/https?:\/\/(?:www\.)?(?:x|twitter)\.com\/([A-Za-z0-9_]{1,15})(?:["'/?])/i)}` : null); + const gh = find(/(https?:\/\/github\.com\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)?)/i); + return { + url: pageUrl, + name: name ? name.slice(0, 80) : null, + tagline: tagline ? tagline.slice(0, 120) : null, + description: description ? description.slice(0, 2000) : null, + icon_url: icon, + x: xHandle && /^@?[A-Za-z0-9_]{1,15}$/.test(xHandle) ? (xHandle.startsWith('@') ? xHandle : `@${xHandle}`) : null, + github: gh, + }; +} + +export async function extractFacts(url: string, f: typeof fetch = guardedFetch): Promise { + const page = await fetchPublic(url, f); + return parseFacts(page.body.toString('utf8'), page.url); +} diff --git a/src/http.ts b/src/http.ts index b90bfaf..357a153 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,11 +1,13 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; import { Hono, type Context } from 'hono'; import { z } from 'zod'; -import { DASHBOARD_HTML, DASHBOARD_JS, LANDING_HTML, INSTALL_SH } from './dashboard.js'; +import { INSTALL_SH } from './installer.js'; +import type { Mailer } from './mail.js'; +import { mountWeb, resolveActor, sameOrigin } from './web.js'; import { buildMcpServer } from './mcp.js'; import type { SubmitFleet } from './service.js'; import { SubmitFleetError } from './util.js'; -import { authenticate, type Actor } from './workspaces.js'; +import type { Actor } from './workspaces.js'; type Env = { Variables: { actor: Actor } }; @@ -28,6 +30,17 @@ const STATUS: Record = { campaign_not_replannable: 409, unsupported_operation: 409, secret_key_missing: 503, + origin_not_allowed: 403, + invalid_credentials: 401, + locked: 429, + rate_limited: 429, + link_invalid: 400, + weak_password: 400, + passkey_failed: 400, + url_not_public: 400, + url_unresolvable: 400, + fetch_failed: 502, + mail_unavailable: 503, }; const productBody = z.object({ @@ -50,6 +63,9 @@ const planBody = z.object({ latest_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), release_scope: z.string().max(100).optional(), destinations: z.array(z.string()).max(1000).optional(), + mode: z.enum(['all', 'auto_only']).optional(), + channels: z.array(z.string().max(50)).max(20).optional(), + include_unknown_pricing: z.boolean().optional(), }); async function body(c: Context, schema: z.ZodType): Promise { @@ -72,7 +88,7 @@ function bearer(c: Context): string | null { return h.startsWith('Bearer ') ? h.slice(7).trim() : null; } -export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[] } = {}): Hono { +export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[]; mailer?: Mailer; publicOrigin?: string } = {}): Hono { const app = new Hono(); const allowed = new Set(opts.allowedOrigins ?? []); @@ -87,11 +103,11 @@ export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[] } app.use('*', async (c, next) => { await next(); c.header('x-content-type-options', 'nosniff'); - c.header('referrer-policy', 'no-referrer'); + // same-origin, not no-referrer: with no-referrer browsers send "Origin: null" on our own form POSTs. + c.header('referrer-policy', 'same-origin'); c.header('x-frame-options', 'DENY'); }); - app.get('/', (c) => c.html(LANDING_HTML)); app.get('/healthz', (c) => c.json({ ok: true })); app.get('/readyz', async (c) => { const checks = await fleet.doctor(); @@ -99,36 +115,20 @@ export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[] } return c.json({ ready, checks }, ready ? 200 : 503); }); app.get('/install.sh', (c) => c.body(INSTALL_SH, 200, { 'content-type': 'text/x-shellscript; charset=utf-8' })); - app.get('/app', (c) => { - c.header('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'"); - return c.html(DASHBOARD_HTML); - }); - app.get('/app/app.js', (c) => c.body(DASHBOARD_JS, 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })); - - // Browsers only reach the API from our own origin; agents and the CLI send no Origin. - const originGuard = async (c: Context, next: () => Promise) => { - // Compare hosts, not full origins: behind nginx the app sees http:// while the - // browser sends https://, and the proxy forwards the public host. + // Bearer tokens (agents, CLI) or the browser session. A cookie-authenticated write must + // come from our own pages (CSRF); cross-site browser calls are refused either way. + const auth = async (c: Context, next: () => Promise) => { const origin = c.req.header('origin'); - if (origin && !allowed.has(origin)) { - let originHost = ''; - try { - originHost = new URL(origin).host; - } catch { - // malformed Origin: refuse below - } - const host = c.req.header('x-forwarded-host') ?? c.req.header('host') ?? new URL(c.req.url).host; - if (originHost !== host) return c.json({ error: 'origin_not_allowed' }, 403); - } + if (origin && !allowed.has(origin) && !sameOrigin(c)) return c.json({ error: 'origin_not_allowed' }, 403); + const r = await resolveActor(fleet, c); + if (!r) return c.json({ error: 'unauthorized' }, 401); + if (r.viaCookie && !['GET', 'HEAD'].includes(c.req.method) && !sameOrigin(c)) return c.json({ error: 'origin_not_allowed' }, 403); + c.set('actor', r.actor); await next(); }; + const originGuard = async (_c: Context, next: () => Promise) => next(); - const auth = async (c: Context, next: () => Promise) => { - const actor = await authenticate(fleet.db, bearer(c)); - if (!actor) return c.json({ error: 'unauthorized' }, 401); - c.set('actor', actor); - await next(); - }; + mountWeb(app, fleet, { mailer: opts.mailer ?? (async () => {}), publicOrigin: opts.publicOrigin }); // ── MCP over Streamable HTTP (stateless: one server per request) ── app.all('/mcp', originGuard, auth, async (c) => { @@ -162,7 +162,11 @@ export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[] } }); api.post('/products/:id/approve-facts', async (c) => c.json(await fleet.productApproveFacts(A(c), c.req.param('id')))); - api.get('/catalog', async (c) => c.json(await fleet.catalogSearch(c.req.query('q'), page(c).limit, page(c).offset))); + api.get('/catalog', async (c) => { + const list = (k: string) => (c.req.query(k) ? c.req.query(k)!.split(',').filter(Boolean) : undefined); + if (c.req.query('product')) return c.json(await fleet.catalogForProduct(A(c), c.req.query('product')!, { channels: list('channel'), pricing: list('pricing'), query: c.req.query('q') })); + return c.json(await fleet.catalogSearch(c.req.query('q'), page(c).limit, page(c).offset, { channels: list('channel'), pricing: list('pricing') })); + }); api.get('/catalog/:slug', async (c) => c.json(await fleet.catalogGet(c.req.param('slug')))); api.post('/catalog/import', async (c) => { const b = await body(c, z.object({ source: z.string(), license: z.string(), records: z.array(z.object({ name: z.string(), url: z.string().url(), kind: z.string().optional(), categories: z.array(z.string()).optional(), labeled_free: z.boolean().optional() })).max(5000) })); @@ -177,7 +181,7 @@ export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[] } api.get('/campaigns', async (c) => c.json(await fleet.campaignList(A(c)))); const plan = (c: Context, b: z.infer, id?: string) => - fleet.campaignPlan(A(c), { productId: b.product_id, minExecutableDestinations: b.min_executable_destinations, spendLimitCents: b.spend_limit_cents, earliestDate: b.earliest_date, latestDate: b.latest_date, releaseScope: b.release_scope, destinations: b.destinations }, id); + fleet.campaignPlan(A(c), { productId: b.product_id, minExecutableDestinations: b.min_executable_destinations, spendLimitCents: b.spend_limit_cents, earliestDate: b.earliest_date, latestDate: b.latest_date, releaseScope: b.release_scope, destinations: b.destinations, mode: b.mode, channels: b.channels, includeUnknownPricing: b.include_unknown_pricing }, id); api.post('/campaigns', async (c) => c.json(await plan(c, await body(c, planBody)), 201)); api.post('/campaigns/:id/plan', async (c) => c.json(await plan(c, await body(c, planBody), c.req.param('id')))); api.get('/campaigns/:id/preview', async (c) => c.json(await fleet.campaignPreview(A(c), c.req.param('id')))); @@ -238,6 +242,35 @@ export function buildApp(fleet: SubmitFleet, opts: { allowedOrigins?: string[] } return c.json({ ok: true }); }); + api.post('/products/extract', async (c) => { + const b = await body(c, z.object({ url: z.string().url().max(2000) })); + return c.json(await fleet.productExtract(b.url)); + }); + api.put('/products/:id/logo', async (c) => { + const bytes = Buffer.from(await c.req.arrayBuffer()); + return c.json(await fleet.productSetLogo(A(c), c.req.param('id'), bytes, c.req.header('content-type') ?? '')); + }); + api.post('/products/:id/logo/from-url', async (c) => { + const b = await body(c, z.object({ url: z.string().url().max(2000) })); + return c.json(await fleet.productLogoFromUrl(A(c), c.req.param('id'), b.url)); + }); + api.get('/catalog-stats', async (c) => c.json(await fleet.catalogStats())); + api.get('/campaigns/:id/queue', async (c) => c.json(await fleet.campaignQueue(A(c), c.req.param('id')))); + api.get('/submissions/:id/kit', async (c) => c.json(await fleet.submissionKit(A(c), c.req.param('id')))); + api.post('/submissions/:id/mark', async (c) => { + const b = await body(c, z.object({ action: z.enum(['submitted', 'live', 'rejected', 'skipped']), listing_url: z.string().max(2000).nullable().optional(), note: z.string().max(2000).nullable().optional() })); + return c.json(await fleet.submissionMark(A(c), c.req.param('id'), b.action, { listing_url: b.listing_url, note: b.note })); + }); + api.get('/keys', async (c) => c.json(await fleet.apiKeyList(A(c)))); + api.post('/keys', async (c) => { + const b = await body(c, z.object({ role: z.enum(['owner', 'agent']), label: z.string().max(80) })); + return c.json(await fleet.apiKeyCreate(A(c), b.role, b.label), 201); + }); + api.delete('/keys/:id', async (c) => { + await fleet.apiKeyRevoke(A(c), c.req.param('id')); + return c.json({ revoked: c.req.param('id') }); + }); + app.route('/api/v1', api); return app; } diff --git a/src/installer.ts b/src/installer.ts new file mode 100644 index 0000000..472d5c1 --- /dev/null +++ b/src/installer.ts @@ -0,0 +1,31 @@ +// The curl installer served at /install.sh. + +export const INSTALL_SH = `#!/bin/sh +# SubmitFleet CLI installer: curl -fsSL https://submitfleet.com/install.sh | sh +set -eu +if ! command -v node >/dev/null 2>&1; then + echo "submitfleet needs Node.js 22.5 or newer: https://nodejs.org/" >&2 + exit 1 +fi +major=$(node -p 'process.versions.node.split(".")[0]') +if [ "$major" -lt 22 ]; then + echo "submitfleet needs Node.js 22.5 or newer (found $(node -v))" >&2 + exit 1 +fi +# npm first: its global bin works out of the box. pnpm only when npm is missing or fails +# (pnpm add -g aborts unless its global bin directory was set up beforehand). +if command -v npm >/dev/null 2>&1 && npm install -g @profullstack/submitfleet; then + bin_dir="$(npm prefix -g)/bin" +elif command -v pnpm >/dev/null 2>&1 && pnpm add -g @profullstack/submitfleet; then + bin_dir="$(pnpm bin -g)" +else + echo "submitfleet: install failed (tried npm and pnpm)" >&2 + exit 1 +fi +if command -v submitfleet >/dev/null 2>&1; then + submitfleet doctor || true + echo "Installed. Try: submitfleet coverage report" +else + echo "Installed to $bin_dir, which is not on your PATH. Add it, then run: submitfleet doctor" +fi +`; diff --git a/src/ledger.ts b/src/ledger.ts index b2d9f17..3bc6240 100644 --- a/src/ledger.ts +++ b/src/ledger.ts @@ -13,6 +13,8 @@ export interface Intent { dedupe_key: string; state: IntentState; reason_code: string | null; + mode: 'auto' | 'assisted'; + note: string | null; payload: Record; payload_hash: string; lease_owner: string | null; diff --git a/src/mail.ts b/src/mail.ts new file mode 100644 index 0000000..df11c94 --- /dev/null +++ b/src/mail.ts @@ -0,0 +1,57 @@ +import { escapeHtml } from './adapters/tinylaunch.js'; + +export interface Mail { + to: string; + subject: string; + text: string; + html: string; +} + +export type Mailer = (m: Mail) => Promise; + +/** Resend over HTTPS. Without a key (local development) the message goes to stderr instead. */ +export function resendMailer(apiKey: string | undefined, from: string, f: typeof fetch = fetch): Mailer { + return async (m) => { + if (!apiKey) { + process.stderr.write(`[mail:dev] to=${m.to} subject=${m.subject}\n${m.text}\n`); + return; + } + const res = await f('https://api.resend.com/emails', { + method: 'POST', + headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' }, + body: JSON.stringify({ from, to: [m.to], subject: m.subject, text: m.text, html: m.html }), + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) throw new Error(`resend ${res.status}: ${(await res.text()).slice(0, 200)}`); + }; +} + +function layout(heading: string, body: string, cta: { label: string; url: string }, footer: string): string { + return ` +
+
+

SubmitFleet

+

${escapeHtml(heading)}

+

${body}

+

${escapeHtml(cta.label)}

+

${footer}

+
`; +} + +export function signupMail(to: string, url: string): Mail { + return { + to, + subject: 'Confirm your SubmitFleet account', + text: `Confirm your email to finish creating your SubmitFleet account:\n\n${url}\n\nThe link works once and expires in 20 minutes. If you did not sign up, ignore this email.`, + html: layout('Confirm your email', 'One click finishes creating your SubmitFleet account.', { label: 'Confirm and sign in', url }, 'The link works once and expires in 20 minutes. If you did not sign up, you can ignore this email.'), + }; +} + +export function loginMail(to: string, url: string, existing: boolean): Mail { + return { + to, + subject: 'Your SubmitFleet sign-in link', + text: `${existing ? 'Sign in to SubmitFleet' : 'Create your SubmitFleet account and sign in'}:\n\n${url}\n\nThe link works once and expires in 20 minutes. If you did not ask for it, ignore this email.`, + html: layout(existing ? 'Sign in to SubmitFleet' : 'Welcome to SubmitFleet', existing ? 'Use this link to sign in. You can add a passkey afterwards for one-tap sign-in.' : 'Use this link to create your account and sign in.', { label: 'Sign in', url }, 'The link works once and expires in 20 minutes. If you did not ask for it, you can ignore this email.'), + }; +} diff --git a/src/mcp.ts b/src/mcp.ts index aadd6b7..1ae29d8 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -78,15 +78,18 @@ export function buildMcpServer(fleet: SubmitFleet, actor: Actor): McpServer { latest_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), release_scope: z.string().max(100).optional(), destinations: z.array(z.string()).max(1000).optional(), + mode: z.enum(['all', 'auto_only']).optional(), + channels: z.array(z.string()).max(20).optional(), + include_unknown_pricing: z.boolean().optional(), campaign_id: z.string().optional(), }; s.registerTool( 'submitfleet_campaign_plan', - { description: 'Plan (or re-plan) a free-only campaign. If fewer than min_executable_destinations (default 100) are executable, the result is coverage_insufficient with the real count and blockers, and nothing can run.', inputSchema: planInput, annotations: write }, + { description: 'Plan (or re-plan) a free-only campaign. Each target is auto (a qualified adapter submits it) or assisted (the owner submits it from a prepared copy kit). mode auto_only is the strict unattended mode: below min_executable_destinations (default 100 there) the result is coverage_insufficient with the real count and blockers, and nothing can run.', inputSchema: planInput, annotations: write }, wrap(async (a) => { const plan = await fleet.campaignPlan( actor, - { productId: a.product_id, minExecutableDestinations: a.min_executable_destinations, spendLimitCents: a.spend_limit_cents, earliestDate: a.earliest_date, latestDate: a.latest_date, releaseScope: a.release_scope, destinations: a.destinations }, + { productId: a.product_id, minExecutableDestinations: a.min_executable_destinations, spendLimitCents: a.spend_limit_cents, earliestDate: a.earliest_date, latestDate: a.latest_date, releaseScope: a.release_scope, destinations: a.destinations, mode: a.mode, channels: a.channels, includeUnknownPricing: a.include_unknown_pricing }, a.campaign_id, ); return { campaign: plan.campaign, counts: plan.counts, blocked: plan.targets.filter((t) => !t.executable).slice(0, 200), executable: plan.targets.filter((t) => t.executable).map((t) => t.destination) }; @@ -108,6 +111,9 @@ export function buildMcpServer(fleet: SubmitFleet, actor: Actor): McpServer { s.registerTool('submitfleet_submission_reconcile', { description: 'Reconcile an uncertain submission, or check a submitted one for publication, using permitted reads only.', inputSchema: { id: z.string() }, annotations: { ...read, openWorldHint: true } }, wrap(async (a) => ({ state: await fleet.submissionReconcile(actor, a.id) }))); s.registerTool('submitfleet_submission_update', { description: 'Update a remote listing if the destination supports it; otherwise unsupported_operation.', inputSchema: { id: z.string() }, annotations: write }, wrap((a) => fleet.submissionUpdate(actor, a.id))); s.registerTool('submitfleet_submission_withdraw', { description: 'Withdraw a remote listing if the destination supports it; otherwise unsupported_operation.', inputSchema: { id: z.string() }, annotations: { ...write, destructiveHint: true } }, wrap((a) => fleet.submissionWithdraw(actor, a.id))); + s.registerTool('submitfleet_campaign_queue', { description: 'Every submission in a campaign: destination, mode (auto or assisted), state and listing URL.', inputSchema: { campaign_id: z.string() }, annotations: read }, wrap((a) => fleet.campaignQueue(actor, a.campaign_id))); + s.registerTool('submitfleet_submission_kit', { description: 'The prepared copy kit for one submission: submit URL, eligibility, pricing note and every field value from the approved product facts.', inputSchema: { id: z.string() }, annotations: read }, wrap((a) => fleet.submissionKit(actor, a.id))); + s.registerTool('submitfleet_catalog_stats', { description: 'Catalog size by channel, how many are free to list, and how many submit automatically.', inputSchema: {}, annotations: read }, wrap(() => fleet.catalogStats())); s.registerTool('submitfleet_human_queue', { description: 'Jobs waiting for a person: challenges, payments, expired sessions, unresolved uncertainty.', inputSchema: {}, annotations: read }, wrap(() => fleet.humanQueue(actor))); s.registerTool('submitfleet_report_get', { description: 'Campaign report: original targets, exclusions, receipts, listing URLs, unresolved jobs, costs. Unknowns stay null.', inputSchema: { campaign_id: z.string() }, annotations: read }, wrap((a) => fleet.reportGet(actor, a.campaign_id))); diff --git a/src/migrations.ts b/src/migrations.ts index 6c6257c..289c83f 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -231,6 +231,118 @@ CREATE TABLE outcomes ( source TEXT NOT NULL, note TEXT ) +`, + }, + { + version: 2, + sql: ` +ALTER TABLE destinations ADD COLUMN channel TEXT; +ALTER TABLE destinations ADD COLUMN description TEXT; +ALTER TABLE destinations ADD COLUMN audience TEXT; +ALTER TABLE destinations ADD COLUMN eligibility TEXT; +ALTER TABLE destinations ADD COLUMN link_type TEXT; +ALTER TABLE destinations ADD COLUMN domain_rating INTEGER; +ALTER TABLE destinations ADD COLUMN monthly_traffic INTEGER; +ALTER TABLE destinations ADD COLUMN website_url TEXT; +ALTER TABLE destinations ADD COLUMN logo_url TEXT; +CREATE INDEX destinations_channel ON destinations (channel, archived); + +ALTER TABLE campaigns ADD COLUMN mode TEXT NOT NULL DEFAULT 'all'; +ALTER TABLE campaigns ADD COLUMN channels_json TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE campaigns ADD COLUMN include_unknown_pricing INTEGER NOT NULL DEFAULT 0; +ALTER TABLE campaign_targets ADD COLUMN mode TEXT; +ALTER TABLE intents ADD COLUMN mode TEXT NOT NULL DEFAULT 'auto'; +ALTER TABLE intents ADD COLUMN note TEXT; + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT, + password_hash TEXT, + email_verified_at TEXT, + failed_logins INTEGER NOT NULL DEFAULT 0, + locked_until TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE memberships ( + user_id TEXT NOT NULL REFERENCES users(id), + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + role TEXT NOT NULL CHECK (role IN ('owner', 'member')), + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, workspace_id) +); + +CREATE TABLE sessions ( + id_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + user_agent TEXT +); +CREATE INDEX sessions_user ON sessions (user_id); + +CREATE TABLE passkeys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + public_key TEXT NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports_json TEXT NOT NULL DEFAULT '[]', + name TEXT, + created_at TEXT NOT NULL, + last_used_at TEXT +); +CREATE INDEX passkeys_user ON passkeys (user_id); + +CREATE TABLE auth_challenges ( + id TEXT PRIMARY KEY, + challenge TEXT NOT NULL, + user_id TEXT, + purpose TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE TABLE email_tokens ( + token_hash TEXT PRIMARY KEY, + email TEXT NOT NULL, + purpose TEXT NOT NULL, + data_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT +); +CREATE INDEX email_tokens_email ON email_tokens (email, created_at); + +CREATE TABLE oauth_codes ( + code_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + client_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, + scope TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT +); + +CREATE TABLE oauth_refresh ( + token_hash TEXT PRIMARY KEY, + family TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id), + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + client_id TEXT NOT NULL, + access_key_id TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + revoked_at TEXT +); +CREATE INDEX oauth_refresh_family ON oauth_refresh (family); + +ALTER TABLE api_keys ADD COLUMN user_id TEXT REFERENCES users(id); +ALTER TABLE api_keys ADD COLUMN expires_at TEXT; +ALTER TABLE api_keys ADD COLUMN last_used_at TEXT; +ALTER TABLE products ADD COLUMN logo_mime TEXT `, }, ]; diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..5a1df55 --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,139 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { all, one, run, type Db } from './db.js'; +import { newId, nowIso, sha256, SubmitFleetError } from './util.js'; +import { userWorkspace } from './auth.js'; + +/** Public clients only (no secrets): PKCE S256 is mandatory, refresh tokens rotate. */ +export const OAUTH_CLIENTS: Record = { + 'submitfleet-cli': { name: 'SubmitFleet CLI', loopback: true }, +}; + +const CODE_MINUTES = 5; +const ACCESS_MINUTES = 60; +const REFRESH_DAYS = 30; +export const SCOPE = 'submitfleet'; + +export function validateClient(clientId: string, redirectUri: string): void { + const c = OAUTH_CLIENTS[clientId]; + if (!c) throw new SubmitFleetError('invalid_client', 'unknown client_id'); + let u: URL; + try { + u = new URL(redirectUri); + } catch { + throw new SubmitFleetError('invalid_request', 'redirect_uri is not a URL'); + } + // RFC 8252: native apps use a loopback redirect on any port, plain http. + const loopback = u.protocol === 'http:' && (u.hostname === '127.0.0.1' || u.hostname === '[::1]' || u.hostname === 'localhost'); + if (!c.loopback || !loopback || u.username || u.password || u.hash) throw new SubmitFleetError('invalid_request', 'redirect_uri must be a loopback address'); +} + +export function validateAuthorizeParams(q: Record): { clientId: string; redirectUri: string; challenge: string; state: string } { + if (q.response_type !== 'code') throw new SubmitFleetError('unsupported_response_type', 'response_type must be code'); + validateClient(q.client_id ?? '', q.redirect_uri ?? ''); + if (q.code_challenge_method !== 'S256' || !/^[A-Za-z0-9_-]{43,128}$/.test(q.code_challenge ?? '')) { + throw new SubmitFleetError('invalid_request', 'PKCE with code_challenge_method=S256 is required'); + } + return { clientId: q.client_id!, redirectUri: q.redirect_uri!, challenge: q.code_challenge!, state: q.state ?? '' }; +} + +export async function issueCode(db: Db, userId: string, clientId: string, redirectUri: string, challenge: string): Promise { + const code = randomBytes(32).toString('base64url'); + await run( + db, + 'INSERT INTO oauth_codes (code_hash, user_id, client_id, redirect_uri, code_challenge, scope, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + sha256(code), userId, clientId, redirectUri, challenge, SCOPE, new Date(Date.now() + CODE_MINUTES * 60_000).toISOString(), + ); + return code; +} + +export interface TokenResponse { + access_token: string; + token_type: 'Bearer'; + expires_in: number; + refresh_token: string; + scope: string; +} + +async function mintPair(db: Db, userId: string, clientId: string, family: string): Promise { + const m = await userWorkspace(db, userId); + if (!m) throw new SubmitFleetError('invalid_grant', 'account has no workspace'); + const access = `sf_oat_${randomBytes(32).toString('base64url')}`; + const keyId = newId('key'); + const ts = nowIso(); + await run( + db, + 'INSERT INTO api_keys (id, workspace_id, role, label, token_hash, created_at, user_id, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + keyId, m.workspace_id, m.role === 'owner' ? 'owner' : 'agent', `oauth:${clientId}`, sha256(access), ts, userId, new Date(Date.now() + ACCESS_MINUTES * 60_000).toISOString(), + ); + const refresh = `sf_ort_${randomBytes(32).toString('base64url')}`; + await run( + db, + 'INSERT INTO oauth_refresh (token_hash, family, user_id, workspace_id, client_id, access_key_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + sha256(refresh), family, userId, m.workspace_id, clientId, keyId, ts, new Date(Date.now() + REFRESH_DAYS * 86_400_000).toISOString(), + ); + return { access_token: access, token_type: 'Bearer', expires_in: ACCESS_MINUTES * 60, refresh_token: refresh, scope: SCOPE }; +} + +export async function exchangeCode(db: Db, p: { code: string; client_id: string; redirect_uri: string; code_verifier: string }): Promise { + const h = sha256(p.code ?? ''); + const ts = nowIso(); + const n = await run(db, 'UPDATE oauth_codes SET used_at = ? WHERE code_hash = ? AND used_at IS NULL AND expires_at > ?', ts, h, ts); + const row = await one<{ user_id: string; client_id: string; redirect_uri: string; code_challenge: string }>(db, 'SELECT * FROM oauth_codes WHERE code_hash = ?', h); + if (n !== 1 || !row) throw new SubmitFleetError('invalid_grant', 'code is invalid, expired or already used'); + if (row.client_id !== p.client_id || row.redirect_uri !== p.redirect_uri) throw new SubmitFleetError('invalid_grant', 'client or redirect_uri mismatch'); + const expected = createHash('sha256').update(p.code_verifier ?? '').digest('base64url'); + if (!p.code_verifier || expected !== row.code_challenge) throw new SubmitFleetError('invalid_grant', 'code_verifier does not match'); + return mintPair(db, row.user_id, row.client_id, newId('fam')); +} + +/** Rotation: each refresh token works once. Presenting a spent one revokes the whole family. */ +export async function refresh(db: Db, token: string, clientId: string): Promise { + const h = sha256(token ?? ''); + const row = await one<{ family: string; user_id: string; client_id: string; expires_at: string; used_at: string | null; revoked_at: string | null }>(db, 'SELECT * FROM oauth_refresh WHERE token_hash = ?', h); + if (!row || row.client_id !== clientId) throw new SubmitFleetError('invalid_grant', 'refresh token is invalid'); + if (row.used_at || row.revoked_at) { + await revokeFamily(db, row.family); + throw new SubmitFleetError('invalid_grant', 'refresh token was already used; sign in again'); + } + if (Date.parse(row.expires_at) <= Date.now()) throw new SubmitFleetError('invalid_grant', 'refresh token expired; sign in again'); + const n = await run(db, 'UPDATE oauth_refresh SET used_at = ? WHERE token_hash = ? AND used_at IS NULL', nowIso(), h); + if (n !== 1) { + await revokeFamily(db, row.family); + throw new SubmitFleetError('invalid_grant', 'refresh token was already used; sign in again'); + } + return mintPair(db, row.user_id, row.client_id, row.family); +} + +export async function revokeFamily(db: Db, family: string): Promise { + const ts = nowIso(); + const keys = await all<{ access_key_id: string | null }>(db, 'SELECT access_key_id FROM oauth_refresh WHERE family = ?', family); + await run(db, 'UPDATE oauth_refresh SET revoked_at = ? WHERE family = ? AND revoked_at IS NULL', ts, family); + for (const k of keys) if (k.access_key_id) await run(db, 'UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL', ts, k.access_key_id); +} + +/** RFC 7009: revoking either token ends the whole sign-in. Unknown tokens are not an error. */ +export async function revokeToken(db: Db, token: string): Promise { + const h = sha256(token ?? ''); + const r = await one<{ family: string }>(db, 'SELECT family FROM oauth_refresh WHERE token_hash = ?', h); + if (r) return revokeFamily(db, r.family); + const k = await one<{ id: string }>(db, 'SELECT id FROM api_keys WHERE token_hash = ?', h); + if (k) { + const f = await one<{ family: string }>(db, 'SELECT family FROM oauth_refresh WHERE access_key_id = ?', k.id); + if (f) return revokeFamily(db, f.family); + await run(db, 'UPDATE api_keys SET revoked_at = ? WHERE id = ?', nowIso(), k.id); + } +} + +export function metadata(origin: string) { + return { + issuer: origin, + authorization_endpoint: `${origin}/oauth/authorize`, + token_endpoint: `${origin}/oauth/token`, + revocation_endpoint: `${origin}/oauth/revoke`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + scopes_supported: [SCOPE], + }; +} diff --git a/src/relevance.ts b/src/relevance.ts new file mode 100644 index 0000000..a4d78ae --- /dev/null +++ b/src/relevance.ts @@ -0,0 +1,47 @@ +import type { Destination } from './catalog.js'; +import type { Product } from './products.js'; + +export type Fit = 'strong' | 'general' | 'weak'; + +/** Catalog categories that restrict who a directory accepts, with what marks a product as belonging. */ +const NICHES: Record = { + ai: { label: 'AI tools only', re: /\b(ai|a\.i\.|artificial intelligence|machine learning|ml|llms?|gpt|chatbot|agents?|genai|generative)\b/i }, + 'developer-tools': { label: 'developer tools only', re: /\b(developers?|devtools?|dev tools?|api|sdk|cli|framework|library|ide|code|coding|github|devops|database|open[- ]source (library|framework|tool))\b/i }, + design: { label: 'design resources only', re: /\b(design|designers?|figma|ui kit|icons?|fonts?|illustrations?|mockups?|templates?)\b/i }, + ecommerce: { label: 'e-commerce only', re: /\b(e-?commerce|shop|store|shopify|woocommerce|checkout|merchants?)\b/i }, + marketing: { label: 'marketing tools only', re: /\b(marketing|seo|ads?|advertising|growth|newsletter|email campaigns?|affiliate)\b/i }, + productivity: { label: 'productivity tools only', re: /\b(productivity|to-?do|tasks?|notes?|calendar|workflow|focus|habits?)\b/i }, +}; +const GENERIC = new Set(['business', 'saas', 'startup', 'indie', 'community', 'tools', 'software']); + +/** Directories that only list things built on one platform. */ +const PLATFORMS: { re: RegExp; needs: RegExp; label: string }[] = [ + { re: /\bstripe (account|app)\b/i, needs: /\bstripe\b/i, label: 'Stripe apps only' }, + { re: /\bshopify (app|store|merchant)/i, needs: /\bshopify\b/i, label: 'Shopify apps only' }, + { re: /\b(chrome|browser) extensions?\b/i, needs: /\b(chrome|browser) extension\b/i, label: 'browser extensions only' }, + { re: /\bwordpress (plugin|theme)s?\b/i, needs: /\bwordpress\b/i, label: 'WordPress plugins only' }, + { re: /\bnotion templates?\b/i, needs: /\bnotion\b/i, label: 'Notion templates only' }, + { re: /\bfigma (plugin|file|community)/i, needs: /\bfigma\b/i, label: 'Figma resources only' }, + { re: /\b(gpts?|custom gpt)s? store\b/i, needs: /\bgpt\b/i, label: 'GPTs only' }, +]; + +function productText(p: Product): string { + return [p.name, p.tagline, p.description, p.categories.join(' '), Object.values(p.facts).join(' ')].filter(Boolean).join(' '); +} + +export function relevance(p: Product, d: Destination): { fit: Fit; reason: string | null; score: number } { + const text = productText(p); + const mine = new Set(p.categories.map((c) => c.toLowerCase())); + const elig = `${d.eligibility ?? ''} ${d.name}`; + for (const pl of PLATFORMS) if (pl.re.test(elig) && !pl.needs.test(text)) return { fit: 'weak', reason: pl.label, score: 0 }; + + const cats = d.categories.map((c) => c.toLowerCase()); + const niche = cats.filter((c) => NICHES[c]); + const matched = niche.filter((c) => mine.has(c) || NICHES[c]!.re.test(text)); + const generic = cats.some((c) => GENERIC.has(c)) || cats.length === 0; + // Launch sites first, then maker-oriented directories, then broad business listings. + const bonus = (d.channel === 'product-launch-site' ? 1 : 0) + (cats.some((c) => c === 'startup' || c === 'indie') ? 0.5 : 0) + (cats.includes('saas') ? 0.25 : 0); + if (matched.length) return { fit: 'strong', reason: null, score: 3 + matched.length + bonus }; + if (niche.length && !generic) return { fit: 'weak', reason: NICHES[niche[0]!]!.label, score: 0 }; + return { fit: 'general', reason: null, score: 1 + bonus }; +} diff --git a/src/remote.ts b/src/remote.ts new file mode 100644 index 0000000..6acd1ed --- /dev/null +++ b/src/remote.ts @@ -0,0 +1,161 @@ +import { spawn } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { SubmitFleetError } from './util.js'; + +export const CLIENT_ID = 'submitfleet-cli'; +export const DEFAULT_URL = 'https://submitfleet.com'; + +export interface StoredAuth { + url: string; + access_token: string; + refresh_token: string; + expires_at: number; +} + +export function authFile(): string { + return process.env.SUBMITFLEET_AUTH_FILE ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'submitfleet', 'auth.json'); +} + +export function readAuth(): StoredAuth | null { + try { + return JSON.parse(readFileSync(authFile(), 'utf8')) as StoredAuth; + } catch { + return null; + } +} + +function writeAuth(a: StoredAuth): void { + const f = authFile(); + mkdirSync(dirname(f), { recursive: true, mode: 0o700 }); + writeFileSync(f, JSON.stringify(a, null, 2), { mode: 0o600 }); + chmodSync(f, 0o600); +} + +function openBrowser(url: string): void { + const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'; + const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]; + try { + spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref(); + } catch { + // no browser available; the URL is printed for the user + } +} + +async function tokenRequest(url: string, body: Record): Promise { + const res = await fetch(`${url}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(body) }); + const j = (await res.json().catch(() => ({}))) as Record; + if (!res.ok || !j.access_token) throw new SubmitFleetError(String(j.error ?? 'login_failed'), String(j.error_description ?? `HTTP ${res.status}`)); + return { url, access_token: String(j.access_token), refresh_token: String(j.refresh_token), expires_at: Date.now() + Number(j.expires_in) * 1000 }; +} + +/** OAuth 2.1 authorization code + PKCE (S256) with a loopback redirect (RFC 8252). */ +export async function login(url = DEFAULT_URL, log: (s: string) => void = (s) => process.stderr.write(`${s}\n`), opts: { open?: boolean; timeoutMs?: number } = {}): Promise { + const base = url.replace(/\/+$/, ''); + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + const state = randomBytes(16).toString('base64url'); + return new Promise((resolve, reject) => { + const server = createServer(async (req, res) => { + const u = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (u.pathname !== '/callback') { + res.writeHead(404).end(); + return; + } + const done = (ok: boolean, msg: string) => { + res.writeHead(ok ? 200 : 400, { 'content-type': 'text/html; charset=utf-8' }); + res.end(`SubmitFleet

${ok ? 'Signed in' : 'Sign-in failed'}

${msg}

`); + }; + try { + if (u.searchParams.get('state') !== state) throw new SubmitFleetError('state_mismatch', 'the response did not match this login; run submitfleet login again'); + if (u.searchParams.get('error')) throw new SubmitFleetError('access_denied', 'access was denied in the browser'); + const code = u.searchParams.get('code'); + if (!code) throw new SubmitFleetError('login_failed', 'no code in the response'); + const auth = await tokenRequest(base, { grant_type: 'authorization_code', code, client_id: CLIENT_ID, redirect_uri: redirect, code_verifier: verifier }); + writeAuth(auth); + done(true, 'You can close this tab and return to the terminal.'); + clearTimeout(timer); + server.close(); + resolve(auth); + } catch (e) { + done(false, e instanceof Error ? e.message : 'unknown error'); + clearTimeout(timer); + server.close(); + reject(e); + } + }); + let redirect = ''; + const timer = setTimeout(() => { + server.close(); + reject(new SubmitFleetError('login_timeout', 'no response from the browser within 5 minutes')); + }, opts.timeoutMs ?? 300_000); + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as { port: number }).port; + redirect = `http://127.0.0.1:${port}/callback`; + const authorize = `${base}/oauth/authorize?${new URLSearchParams({ response_type: 'code', client_id: CLIENT_ID, redirect_uri: redirect, code_challenge: challenge, code_challenge_method: 'S256', state })}`; + log(`Opening your browser to sign in. If it does not open, visit:\n\n ${authorize}\n`); + if (opts.open !== false) openBrowser(authorize); + }); + }); +} + +export async function logout(): Promise { + const a = readAuth(); + if (!a) return false; + await fetch(`${a.url}/oauth/revoke`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: a.refresh_token }) }).catch(() => {}); + if (existsSync(authFile())) rmSync(authFile()); + return true; +} + +/** Access token, refreshed (and rotated) when it is about to expire. */ +export async function accessToken(): Promise { + const a = readAuth(); + if (!a) throw new SubmitFleetError('not_logged_in', 'run submitfleet login first'); + if (a.expires_at - Date.now() > 60_000) return a; + try { + const next = await tokenRequest(a.url, { grant_type: 'refresh_token', refresh_token: a.refresh_token, client_id: CLIENT_ID }); + writeAuth(next); + return next; + } catch (e) { + throw new SubmitFleetError('session_expired', `sign in again with submitfleet login (${e instanceof Error ? e.message : e})`); + } +} + +export async function remote(method: string, path: string, body?: unknown): Promise { + const a = await accessToken(); + const res = await fetch(`${a.url}${path.startsWith('/api') ? path : `/api/v1${path}`}`, { + method, + headers: { authorization: `Bearer ${a.access_token}`, ...(body ? { 'content-type': 'application/json' } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + const type = res.headers.get('content-type') ?? ''; + const data = type.includes('json') ? await res.json() : await res.text(); + if (!res.ok) { + const d = data as { error?: string; message?: string; detail?: unknown }; + throw new SubmitFleetError(d.error ?? `http_${res.status}`, d.message ?? `HTTP ${res.status}`, d.detail); + } + return data; +} + +/** Bridges a stdio MCP client to the hosted Streamable HTTP endpoint, one JSON-RPC message per line. */ +export async function mcpStdioProxy(): Promise { + const rl = createInterface({ input: process.stdin }); + for await (const line of rl) { + if (!line.trim()) continue; + const a = await accessToken(); + const res = await fetch(`${a.url}/mcp`, { + method: 'POST', + headers: { authorization: `Bearer ${a.access_token}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: line, + }); + const text = await res.text(); + if (!text.trim()) continue; + // A JSON response is one message; an SSE stream carries messages on data: lines. + if (text.trimStart().startsWith('{') || text.trimStart().startsWith('[')) process.stdout.write(`${text.trim()}\n`); + else for (const l of text.split('\n')) if (l.startsWith('data:')) process.stdout.write(`${l.slice(5).trim()}\n`); + } +} diff --git a/src/service.ts b/src/service.ts index 3d5c1a6..861d335 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1,11 +1,17 @@ +import { readFileSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; -import { resolve, sep } from 'node:path'; +import { dirname, join, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildKit } from './assisted.js'; +import { relevance } from './relevance.js'; +import { extractFacts, fetchPublic } from './extract.js'; +import { mkdir, writeFile } from 'node:fs/promises'; import { builtinAdapters } from './adapters/index.js'; import type { Adapter } from './adapters/types.js'; import { tinyLaunchRequestCode, tinyLaunchVerify } from './adapters/tinylaunch.js'; import * as campaigns from './campaigns.js'; import * as catalog from './catalog.js'; -import { all, openDb, run, type Db } from './db.js'; +import { all, one, openDb, run, type Db } from './db.js'; import * as ledger from './ledger.js'; import * as products from './products.js'; import * as reports from './reports.js'; @@ -23,8 +29,19 @@ export interface SubmitFleetConfig { adapters?: Map; fetch?: typeof fetch; now?: () => number; + /** Public origin used in links handed to destinations (logo URLs) and in emails. */ + publicOrigin?: string; + /** Fetch used for owner-supplied URLs (autofill, logo import); must refuse private addresses. */ + publicFetch?: typeof fetch; } +const LOGO_TYPES: Record boolean }> = { + 'image/png': { ext: 'png', magic: (b) => b.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) }, + 'image/jpeg': { ext: 'jpg', magic: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff }, + 'image/webp': { ext: 'webp', magic: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP' }, +}; +export const MAX_LOGO_BYTES = 1_000_000; + const MAX_ASSET_BYTES = 5 * 1024 * 1024; export class SubmitFleet { @@ -85,7 +102,8 @@ export class SubmitFleet { }; // ── Catalog ── - catalogSearch = (query?: string, limit?: number, offset?: number) => catalog.listDestinations(this.db, { query, limit, offset }); + catalogSearch = (query?: string, limit?: number, offset?: number, filter: { channels?: string[]; pricing?: string[] } = {}) => + catalog.listDestinations(this.db, { query, limit, offset, ...filter }); catalogGet = async (slug: string) => { const d = await catalog.getDestination(this.db, slug); if (!d) throw new SubmitFleetError('not_found', `destination ${slug}`); @@ -182,6 +200,7 @@ export class SubmitFleet { ws.requireOwner(a); const i = await ledger.getIntent(this.db, a.workspaceId, id); if (i.state !== 'needs_human') throw new SubmitFleetError('invalid_state', `state is ${i.state}`); + if (i.mode === 'assisted') throw new SubmitFleetError('invalid_state', 'assisted submissions are completed by marking them submitted or skipped'); return ledger.transition(this.db, i, 'queued', { reason_code: 'owner_requeued' }); }; @@ -214,6 +233,161 @@ export class SubmitFleet { await run(this.db, 'INSERT INTO outcomes (id, intent_id, observed_at, metric, value, source) VALUES (?, ?, ?, ?, ?, ?)', newId('out'), intentId, nowIso(), metric, value, source); }; + // ── Assisted submissions ── + submissionKit = async (a: Actor, id: string) => { + const i = await ledger.getIntent(this.db, a.workspaceId, id); + const product = await products.requireProduct(this.db, a.workspaceId, i.product_id); + const d = await this.requireDestination(i.destination_id); + const logo = product.logo_path ? `${this.config.publicOrigin ?? ''}/p/${product.id}/logo` : null; + return { submission: { id: i.id, state: i.state, mode: i.mode, reason_code: i.reason_code, listing_url: i.listing_url, note: i.note }, ...buildKit(product, d, logo) }; + }; + + /** Owner-reported outcome of an assisted submission (or a published/rejected check of any submission). */ + submissionMark = async (a: Actor, id: string, action: 'submitted' | 'live' | 'rejected' | 'skipped', opts: { listing_url?: string | null; note?: string | null } = {}) => { + ws.requireOwner(a); + const i = await ledger.getIntent(this.db, a.workspaceId, id); + const url = opts.listing_url ? safeHttpUrl(opts.listing_url) : null; + const ts = nowIso(this.now); + const evidence = { by: 'owner', actor: a.id, note: opts.note ?? null }; + let ok = false; + if (action === 'submitted') { + if (i.state !== 'needs_human') throw new SubmitFleetError('invalid_state', `state is ${i.state}`); + ok = await ledger.transition(this.db, i, 'submitted', { external_action_performed: true, receipt_id: `owner:${ts}`, listing_url: url, submitted_at: ts, reason_code: 'owner_submitted', evidence }); + } else if (action === 'live') { + if (!url) throw new SubmitFleetError('invalid_request', 'listing_url is required to mark a listing live'); + if (!['submitted', 'scheduled', 'under_review'].includes(i.state)) throw new SubmitFleetError('invalid_state', `state is ${i.state}`); + ok = await ledger.transition(this.db, i, 'published_verified', { listing_url: url, published_at: ts, reason_code: 'owner_confirmed_live', evidence }); + } else if (action === 'rejected') { + if (!['submitted', 'scheduled', 'under_review'].includes(i.state)) throw new SubmitFleetError('invalid_state', `state is ${i.state}`); + ok = await ledger.transition(this.db, i, 'rejected', { reason_code: 'owner_reported_rejected', evidence }); + } else { + if (i.state !== 'needs_human') throw new SubmitFleetError('invalid_state', `state is ${i.state}`); + ok = await ledger.transition(this.db, i, 'failed', { reason_code: `skipped${opts.note ? `:${opts.note.slice(0, 80)}` : ''}`, release_dedupe: !i.external_action_performed, evidence }); + } + if (opts.note) await run(this.db, 'UPDATE intents SET note = ? WHERE id = ?', opts.note.slice(0, 2000), i.id); + return { ok, state: (await ledger.getIntent(this.db, a.workspaceId, id)).state }; + }; + + /** Every submission in a campaign with what the owner needs to work through it. */ + campaignQueue = async (a: Actor, campaignId: string) => { + const c = await campaigns.getCampaign(this.db, a.workspaceId, campaignId); + const product = await products.requireProduct(this.db, a.workspaceId, c.product_id); + const rank: Record = { needs_human: 0, uncertain: 1, queued: 2 }; + const rows = await all & { id: string; destination: string; state: string; domain_rating: number | null }>( + this.db, + `SELECT i.id, i.state, i.mode, i.reason_code, i.listing_url, i.submitted_at, i.published_at, i.updated_at, + d.slug AS destination, d.name, d.channel, d.submission_url, d.domain_rating, d.monthly_traffic, d.link_type, d.logo_url + FROM intents i JOIN destinations d ON d.id = i.destination_id + WHERE i.workspace_id = ? AND i.campaign_id = ? +`, + a.workspaceId, + campaignId, + ); + const fits = new Map>(); + for (const r of rows) { + const d = await catalog.getDestination(this.db, r.destination); + if (d) fits.set(r.destination, relevance(product, d)); + } + return rows + .map((r) => ({ ...r, fit: fits.get(r.destination)?.fit ?? 'general', fit_reason: fits.get(r.destination)?.reason ?? null })) + .sort((x, y) => (rank[x.state] ?? 3) - (rank[y.state] ?? 3) || (fits.get(y.destination)?.score ?? 0) - (fits.get(x.destination)?.score ?? 0) || (y.domain_rating ?? -1) - (x.domain_rating ?? -1)); + }; + + // ── Product assets and autofill ── + /** Reads what the product's own page says about it; the owner edits and approves. */ + productExtract = async (url: string) => extractFacts(url, this.config.publicFetch); + + productSetLogo = async (a: Actor, id: string, bytes: Buffer, mime: string) => { + const t = LOGO_TYPES[mime.split(';')[0]!.trim().toLowerCase()]; + if (!t) throw new SubmitFleetError('invalid_request', 'logo must be PNG, JPEG or WebP'); + if (bytes.length > MAX_LOGO_BYTES) throw new SubmitFleetError('invalid_request', 'logo must be under 1 MB'); + if (!t.magic(bytes)) throw new SubmitFleetError('invalid_request', 'that file is not the image type it claims to be'); + const p = await products.requireProduct(this.db, a.workspaceId, id); + const rel = `${a.workspaceId}/${p.id}.${t.ext}`; + await mkdir(join(resolve(this.config.assetsDir), a.workspaceId), { recursive: true }); + await writeFile(join(resolve(this.config.assetsDir), rel), bytes); + await products.updateProduct(this.db, a.workspaceId, id, { logo_path: rel }); + await run(this.db, 'UPDATE products SET logo_mime = ? WHERE id = ?', mime, id); + return products.requireProduct(this.db, a.workspaceId, id); + }; + + productLogoFromUrl = async (a: Actor, id: string, url: string) => { + const r = await fetchPublic(url, this.config.publicFetch, 'image/png,image/jpeg,image/webp'); + const mime = Object.keys(LOGO_TYPES).find((m) => LOGO_TYPES[m]!.magic(r.body)); + if (!mime) throw new SubmitFleetError('invalid_request', 'that URL is not a PNG, JPEG or WebP image'); + return this.productSetLogo(a, id, r.body, mime); + }; + + /** Public logo bytes for a product, so a directory form can take a logo URL. */ + productLogo = async (productId: string): Promise<{ bytes: Buffer; mime: string } | null> => { + const r = await one<{ logo_path: string | null; logo_mime: string | null }>(this.db, 'SELECT logo_path, logo_mime FROM products WHERE id = ?', productId); + if (!r?.logo_path) return null; + try { + return { bytes: await this.readAsset(r.logo_path), mime: r.logo_mime ?? 'image/png' }; + } catch { + return null; + } + }; + + // ── API keys for agents and scripts ── + apiKeyList = (a: Actor) => ws.listApiKeys(this.db, a.workspaceId); + apiKeyCreate = async (a: Actor, role: ws.Role, label: string) => { + ws.requireOwner(a); + return ws.createApiKey(this.db, a.workspaceId, role, label.slice(0, 80) || `${role} key`); + }; + apiKeyRevoke = async (a: Actor, id: string) => { + ws.requireOwner(a); + return ws.revokeApiKey(this.db, a.workspaceId, id); + }; + + /** Catalog entries with how well each fits one product, best fit first. */ + catalogForProduct = async (a: Actor, productId: string, filter: { channels?: string[]; pricing?: string[]; query?: string } = {}) => { + const product = await products.requireProduct(this.db, a.workspaceId, productId); + const ds = await catalog.listDestinations(this.db, { limit: 2000, ...filter }); + return ds + .map((d) => ({ ...d, ...relevance(product, d) })) + .sort((x, y) => y.score - x.score || (y.domain_rating ?? -1) - (x.domain_rating ?? -1)); + }; + + private iconCache = new Map(); + /** Destination icons through our own origin: cached, address-checked, and no visitor IPs sent to third parties. */ + destinationIcon = async (slug: string): Promise<{ bytes: Buffer; type: string } | null> => { + if (this.iconCache.has(slug)) return this.iconCache.get(slug)!; + const d = await catalog.getDestination(this.db, slug); + let out: { bytes: Buffer; type: string } | null = null; + if (d?.logo_url) { + try { + const r = await fetchPublic(d.logo_url, this.config.publicFetch, 'image/*'); + const t = r.type.split(';')[0]!.trim(); + if (/^image\/(png|jpeg|gif|webp|x-icon|vnd\.microsoft\.icon)$/.test(t) && r.body.length < 200_000) out = { bytes: r.body, type: t }; + } catch { + out = null; + } + } + if (this.iconCache.size > 2000) this.iconCache.clear(); + this.iconCache.set(slug, out); + return out; + }; + + catalogStats = () => catalog.catalogStats(this.db, new Set(this.adapters.keys())); + + /** Loads the bundled curated catalog (CC0) and the reviewed seed records. Idempotent. */ + loadBundledCatalog = async () => { + const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'catalog'); + const list = JSON.parse(readFileSync(join(root, 'submitlist.json'), 'utf8')) as { destinations: catalog.SubmitlistRecord[]; source?: string }; + const imported = await catalog.importSubmitlist(this.db, list.destinations, list.source ?? 'https://github.com/alvinunreal/awesome-submitlist'); + const seed = JSON.parse(readFileSync(join(root, 'seed.json'), 'utf8')) as { destinations: catalog.DestinationInput[] }; + let seeded = 0; + for (const d of seed.destinations) { + const existing = await catalog.getDestination(this.db, d.slug); + // Reviewed seed facts apply once; later reviewed edits are never overwritten by a boot. + if (existing?.adapter_id) continue; + await catalog.upsertDestination(this.db, d, 'reviewed'); + seeded++; + } + return { ...imported, seeded }; + }; + humanQueue = async (a: Actor) => all(this.db, "SELECT i.id, i.campaign_id, d.slug AS destination, i.state, i.reason_code, i.updated_at FROM intents i JOIN destinations d ON d.id = i.destination_id WHERE i.workspace_id = ? AND i.state IN ('needs_human', 'uncertain') ORDER BY i.updated_at", a.workspaceId); @@ -239,3 +413,14 @@ export class SubmitFleet { return checks; }; } + +function safeHttpUrl(raw: string): string { + let u: URL; + try { + u = new URL(raw.trim()); + } catch { + throw new SubmitFleetError('invalid_request', 'listing_url must be a URL'); + } + if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new SubmitFleetError('invalid_request', 'listing_url must be http(s)'); + return u.toString(); +} diff --git a/src/web.ts b/src/web.ts new file mode 100644 index 0000000..bf69aad --- /dev/null +++ b/src/web.ts @@ -0,0 +1,336 @@ +import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/server'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, extname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Context, Hono } from 'hono'; +import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; +import { z } from 'zod'; +import * as auth from './auth.js'; +import { escapeHtml } from './adapters/tinylaunch.js'; +import { loginMail, signupMail, type Mailer } from './mail.js'; +import * as oauth from './oauth.js'; +import type { SubmitFleet } from './service.js'; +import { SubmitFleetError } from './util.js'; +import { authenticate, type Actor } from './workspaces.js'; + +export const WEB_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'web'); + +const TYPES: Record = { + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.webmanifest': 'application/manifest+json', + '.html': 'text/html; charset=utf-8', +}; + +export const PAGE_CSP = + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self' http://127.0.0.1:* http://localhost:*"; + +export interface WebOptions { + mailer: Mailer; + /** Canonical public origin, e.g. https://submitfleet.com. Falls back to the request's forwarded origin. */ + publicOrigin?: string; +} + +export function requestOrigin(c: Context): string { + const proto = c.req.header('x-forwarded-proto') ?? new URL(c.req.url).protocol.replace(':', ''); + const host = c.req.header('x-forwarded-host') ?? c.req.header('host') ?? new URL(c.req.url).host; + return `${proto}://${host}`; +} + +export function clientIp(c: Context): string { + return c.req.header('x-real-ip') ?? c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? 'local'; +} + +/** Browser-originated state changes must come from our own pages. */ +export function sameOrigin(c: Context): boolean { + const origin = c.req.header('origin'); + if (!origin) return false; + try { + return new URL(origin).host === (c.req.header('x-forwarded-host') ?? c.req.header('host') ?? new URL(c.req.url).host); + } catch { + return false; + } +} + +/** Bearer API key / OAuth token, or the signed-in browser session. */ +export async function resolveActor(fleet: SubmitFleet, c: Context): Promise<{ actor: Actor; viaCookie: boolean } | null> { + const h = c.req.header('authorization') ?? ''; + if (h.startsWith('Bearer ')) { + const actor = await authenticate(fleet.db, h.slice(7).trim()); + return actor ? { actor, viaCookie: false } : null; + } + const user = await auth.sessionUser(fleet.db, getCookie(c, auth.SESSION_COOKIE)); + if (!user) return null; + const actor = await auth.actorForUser(fleet.db, user.id); + return actor ? { actor, viaCookie: true } : null; +} + +function loadStatic(): Map { + const files = new Map(); + const walk = (dir: string, prefix: string) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.isDirectory()) walk(join(dir, e.name), `${prefix}${e.name}/`); + else files.set(`${prefix}${e.name}`, { body: readFileSync(join(dir, e.name)), type: TYPES[extname(e.name)] ?? 'application/octet-stream' }); + } + }; + walk(WEB_ROOT, ''); + return files; +} + +function safeNext(raw: string | undefined): string { + // Only same-site paths: never "//evil", never a scheme. + return raw && /^\/(?!\/)[\w\-./?=&%#]*$/.test(raw) ? raw : '/app'; +} + +export function mountWeb(app: Hono<{ Variables: { actor: Actor } }>, fleet: SubmitFleet, opts: WebOptions): void { + const files = loadStatic(); + const page = (name: string) => files.get(name)!.body.toString('utf8'); + const origin = (c: Context) => opts.publicOrigin ?? requestOrigin(c); + const rp = (c: Context): auth.RelyingParty => { + const o = origin(c); + return { origin: o, rpID: new URL(o).hostname, rpName: 'SubmitFleet' }; + }; + const html = (c: Context, body: string, status = 200) => { + c.header('content-security-policy', PAGE_CSP); + c.header('cache-control', 'no-store'); + return c.html(body, status as 200); + }; + const startSession = async (c: Context, userId: string) => { + const s = await auth.createSession(fleet.db, userId, c.req.header('user-agent') ?? null); + setCookie(c, auth.SESSION_COOKIE, s.token, { + httpOnly: true, + secure: origin(c).startsWith('https:'), + sameSite: 'Lax', + path: '/', + expires: s.expires, + }); + }; + const limited = (c: Context, key: string, n: number, minutes: number) => !auth.allow(`${key}:${clientIp(c)}`, n, minutes * 60_000); + const guard = (c: Context) => { + if (!sameOrigin(c)) throw new SubmitFleetError('origin_not_allowed', 'cross-site request refused'); + }; + const json = async (c: Context, schema: z.ZodType): Promise => { + const r = schema.safeParse(await c.req.json().catch(() => ({}))); + if (!r.success) throw new SubmitFleetError('invalid_request', r.error.issues[0]?.message ?? 'invalid body'); + return r.data; + }; + /** Sends at most 3 emails per address per 15 minutes; the caller answers the same either way. */ + const sendLink = async (c: Context, email: string, purpose: auth.LinkPurpose, data: Record = {}) => { + if ((await auth.recentEmailCount(fleet.db, email)) >= 3) return; + const existing = await auth.getUserByEmail(fleet.db, email); + // An existing account always gets a plain sign-in link, so sign-up never reveals or overwrites anything. + const p: auth.LinkPurpose = existing ? 'login' : purpose; + const token = await auth.createEmailToken(fleet.db, email, p, existing ? {} : data); + const url = `${origin(c)}/auth/link#t=${token}`; + try { + await opts.mailer(p === 'signup' ? signupMail(email, url) : loginMail(email, url, !!existing)); + } catch (e) { + console.error('[submitfleet] mail send failed:', e instanceof Error ? e.message : e); + throw new SubmitFleetError('mail_unavailable', 'We could not send the email just now. Try again in a minute, or sign in with a passkey or password.'); + } + }; + const SENT = { ok: true, message: 'If that address can receive mail, a link is on its way. It expires in 20 minutes.' }; + + // ── Pages ── + app.get('/', async (c) => { + const stats = await fleet.catalogStats(); + const body = page('index.html') + .replaceAll('{{TOTAL}}', String(stats.total)) + .replaceAll('{{FREE}}', String(stats.free)) + .replaceAll('{{DIRECTORIES}}', String((stats.by_channel.directory ?? 0) + (stats.by_channel.citation ?? 0))) + .replaceAll('{{LAUNCH}}', String(stats.by_channel['product-launch-site'] ?? 0)) + .replaceAll('{{AUTOMATED}}', String(stats.automated)); + return html(c, body); + }); + for (const p of ['/signup', '/login']) app.get(p, (c) => html(c, page('auth.html'))); + app.get('/auth/link', (c) => html(c, page('link.html'))); + app.get('/docs', (c) => html(c, page('docs.html'))); + const appShell = async (c: Context) => { + const user = await auth.sessionUser(fleet.db, getCookie(c, auth.SESSION_COOKIE)); + if (!user) return c.redirect(`/login?next=${encodeURIComponent(new URL(c.req.url).pathname)}`); + return html(c, page('app.html')); + }; + app.get('/app', appShell); + app.get('/app/*', appShell); + app.get('/assets/*', (c) => { + const f = files.get(c.req.path.replace(/^\//, '')); + if (!f) return c.notFound(); + return c.body(new Uint8Array(f.body), 200, { 'content-type': f.type, 'cache-control': 'public, max-age=300' }); + }); + app.get('/favicon.ico', (c) => c.body(new Uint8Array(files.get('assets/icon.svg')!.body), 200, { 'content-type': 'image/svg+xml' })); + app.get('/d/:slug/icon', async (c) => { + const i = await fleet.destinationIcon(c.req.param('slug')); + if (!i) return c.body(new Uint8Array(files.get('assets/dest.svg')!.body), 200, { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=86400' }); + return c.body(new Uint8Array(i.bytes), 200, { 'content-type': i.type, 'cache-control': 'public, max-age=86400', 'content-security-policy': "default-src 'none'" }); + }); + app.get('/p/:id/logo', async (c) => { + const l = await fleet.productLogo(c.req.param('id')); + if (!l) return c.notFound(); + return c.body(new Uint8Array(l.bytes), 200, { 'content-type': l.mime, 'cache-control': 'public, max-age=3600', 'content-security-policy': "default-src 'none'" }); + }); + + // ── Auth API ── + const emailBody = z.object({ email: z.string().max(254) }); + app.post('/api/auth/signup', async (c) => { + guard(c); + const b = await json(c, z.object({ email: z.string().max(254), password: z.string().max(200).optional(), name: z.string().max(80).optional() })); + const email = auth.normalizeEmail(b.email); + if (b.password) auth.checkPasswordStrength(b.password); + if (limited(c, 'signup', 10, 10)) return c.json(SENT); + await sendLink(c, email, 'signup', { name: b.name?.trim() || null, password_hash: b.password ? await auth.hashPassword(b.password) : null }); + return c.json(SENT); + }); + app.post('/api/auth/magic', async (c) => { + guard(c); + const b = await json(c, emailBody); + const email = auth.normalizeEmail(b.email); + if (limited(c, 'magic', 10, 10)) return c.json(SENT); + await sendLink(c, email, 'login'); + return c.json(SENT); + }); + app.post('/api/auth/link', async (c) => { + guard(c); + const b = await json(c, z.object({ token: z.string().min(20).max(100) })); + if (limited(c, 'link', 30, 10)) throw new SubmitFleetError('rate_limited', 'too many attempts; wait a few minutes'); + const r = await auth.consumeEmailToken(fleet.db, b.token); + await startSession(c, r.user.id); + return c.json({ ok: true, created: r.created, user: { email: r.user.email } }); + }); + app.post('/api/auth/login', async (c) => { + guard(c); + const b = await json(c, z.object({ email: z.string().max(254), password: z.string().max(200) })); + if (limited(c, 'login', 20, 10)) throw new SubmitFleetError('rate_limited', 'too many attempts; wait a few minutes or use an emailed link'); + const user = await auth.loginWithPassword(fleet.db, b.email, b.password); + await startSession(c, user.id); + return c.json({ ok: true }); + }); + app.post('/api/auth/logout', async (c) => { + guard(c); + const t = getCookie(c, auth.SESSION_COOKIE); + if (t) await auth.deleteSession(fleet.db, t); + deleteCookie(c, auth.SESSION_COOKIE, { path: '/' }); + return c.json({ ok: true }); + }); + app.post('/api/auth/passkey/login/options', async (c) => { + guard(c); + if (limited(c, 'pk', 30, 10)) throw new SubmitFleetError('rate_limited', 'too many attempts'); + return c.json(await auth.passkeyLoginOptions(fleet.db, rp(c))); + }); + app.post('/api/auth/passkey/login/verify', async (c) => { + guard(c); + const b = await json(c, z.object({ challenge_id: z.string(), response: z.record(z.string(), z.unknown()) })); + const user = await auth.passkeyLogin(fleet.db, rp(c), b.challenge_id, b.response as unknown as AuthenticationResponseJSON); + await startSession(c, user.id); + return c.json({ ok: true }); + }); + + // ── Account (browser session) ── + const sessionUser = async (c: Context) => { + const u = await auth.sessionUser(fleet.db, getCookie(c, auth.SESSION_COOKIE)); + if (!u) throw new SubmitFleetError('unauthorized', 'sign in first'); + return u; + }; + app.get('/api/me', async (c) => { + const r = await resolveActor(fleet, c); + if (!r) return c.json({ error: 'unauthorized' }, 401); + const userId = r.actor.id.startsWith('user:') ? r.actor.id.slice(5) : null; + const user = userId ? await auth.getUser(fleet.db, userId) : null; + return c.json({ + workspace_id: r.actor.workspaceId, + role: r.actor.role, + user: user ? { id: user.id, email: user.email, name: user.name, created_at: user.created_at } : null, + has_password: user ? await auth.hasPassword(fleet.db, user.id) : null, + passkeys: user ? await auth.listPasskeys(fleet.db, user.id) : [], + }); + }); + app.post('/api/account/password', async (c) => { + guard(c); + const u = await sessionUser(c); + const b = await json(c, z.object({ password: z.string().max(200) })); + await auth.setPassword(fleet.db, u.id, b.password); + return c.json({ ok: true }); + }); + app.post('/api/account/passkeys/options', async (c) => { + guard(c); + return c.json(await auth.passkeyRegistrationOptions(fleet.db, rp(c), await sessionUser(c))); + }); + app.post('/api/account/passkeys', async (c) => { + guard(c); + const u = await sessionUser(c); + const b = await json(c, z.object({ challenge_id: z.string(), response: z.record(z.string(), z.unknown()), name: z.string().max(60).optional() })); + return c.json(await auth.passkeyRegister(fleet.db, rp(c), u, b.challenge_id, b.response as unknown as RegistrationResponseJSON, b.name)); + }); + app.delete('/api/account/passkeys/:id', async (c) => { + guard(c); + await auth.deletePasskey(fleet.db, (await sessionUser(c)).id, c.req.param('id')); + return c.json({ ok: true }); + }); + + // ── OAuth 2.1 for the CLI ── + app.get('/.well-known/oauth-authorization-server', (c) => c.json(oauth.metadata(origin(c)))); + app.get('/oauth/authorize', async (c) => { + const q = c.req.query(); + let params; + try { + params = oauth.validateAuthorizeParams(q); + } catch (e) { + return html(c, page('error.html').replace('{{MESSAGE}}', escapeHtml(e instanceof Error ? e.message : 'invalid request')), 400); + } + const user = await auth.sessionUser(fleet.db, getCookie(c, auth.SESSION_COOKIE)); + if (!user) return c.redirect(`/login?next=${encodeURIComponent(new URL(c.req.url).pathname + new URL(c.req.url).search)}`); + const hidden = Object.entries({ client_id: params.clientId, redirect_uri: params.redirectUri, code_challenge: params.challenge, state: params.state }) + .map(([k, v]) => ``) + .join(''); + return html( + c, + page('authorize.html') + .replaceAll('{{CLIENT}}', escapeHtml(oauth.OAUTH_CLIENTS[params.clientId]!.name)) + .replaceAll('{{EMAIL}}', escapeHtml(user.email)) + .replace('{{HIDDEN}}', hidden), + ); + }); + app.post('/oauth/authorize', async (c) => { + guard(c); + const user = await sessionUser(c); + const f = await c.req.parseBody(); + const get = (k: string) => (typeof f[k] === 'string' ? (f[k] as string) : undefined); + const p = oauth.validateAuthorizeParams({ response_type: 'code', code_challenge_method: 'S256', client_id: get('client_id'), redirect_uri: get('redirect_uri'), code_challenge: get('code_challenge'), state: get('state') }); + const target = new URL(p.redirectUri); + if (get('decision') !== 'allow') { + target.searchParams.set('error', 'access_denied'); + } else { + target.searchParams.set('code', await oauth.issueCode(fleet.db, user.id, p.clientId, p.redirectUri, p.challenge)); + } + if (p.state) target.searchParams.set('state', p.state); + return c.redirect(target.toString(), 302); + }); + const tokenBody = async (c: Context): Promise> => { + const type = c.req.header('content-type') ?? ''; + if (type.includes('application/json')) return (await c.req.json().catch(() => ({}))) as Record; + const f = await c.req.parseBody(); + return Object.fromEntries(Object.entries(f).filter(([, v]) => typeof v === 'string')) as Record; + }; + app.post('/oauth/token', async (c) => { + c.header('cache-control', 'no-store'); + const b = await tokenBody(c); + try { + if (limited(c, 'token', 60, 10)) throw new SubmitFleetError('invalid_request', 'too many requests'); + if (b.grant_type === 'authorization_code') { + return c.json(await oauth.exchangeCode(fleet.db, { code: b.code ?? '', client_id: b.client_id ?? '', redirect_uri: b.redirect_uri ?? '', code_verifier: b.code_verifier ?? '' })); + } + if (b.grant_type === 'refresh_token') return c.json(await oauth.refresh(fleet.db, b.refresh_token ?? '', b.client_id ?? '')); + throw new SubmitFleetError('unsupported_grant_type', 'grant_type must be authorization_code or refresh_token'); + } catch (e) { + const code = e instanceof SubmitFleetError ? e.code : 'server_error'; + return c.json({ error: code, error_description: e instanceof Error ? e.message : '' }, code === 'server_error' ? 500 : 400); + } + }); + app.post('/oauth/revoke', async (c) => { + const b = await tokenBody(c); + if (b.token) await oauth.revokeToken(fleet.db, b.token); + return c.body(null, 200); + }); +} diff --git a/src/workspaces.ts b/src/workspaces.ts index ed72724..6c4060a 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -32,16 +32,26 @@ export async function createApiKey(db: Db, workspaceId: string, role: Role, labe export async function authenticate(db: Db, token: string | undefined | null): Promise { if (!token) return null; - const r = await one<{ id: string; workspace_id: string; role: Role }>( + const r = await one<{ id: string; workspace_id: string; role: Role; expires_at: string | null; last_used_at: string | null; user_id: string | null }>( db, - 'SELECT id, workspace_id, role FROM api_keys WHERE token_hash = ? AND revoked_at IS NULL', + 'SELECT id, workspace_id, role, expires_at, last_used_at, user_id FROM api_keys WHERE token_hash = ? AND revoked_at IS NULL', sha256(token), ); - return r ? { id: r.id, workspaceId: r.workspace_id, role: r.role } : null; + if (!r || (r.expires_at && Date.parse(r.expires_at) <= Date.now())) return null; + // Coarse last-used stamp (once a minute) so a busy agent does not write on every call. + if (!r.last_used_at || Date.now() - Date.parse(r.last_used_at) > 60_000) await run(db, 'UPDATE api_keys SET last_used_at = ? WHERE id = ?', nowIso(), r.id); + // A token issued to a signed-in person (CLI login) acts as that person; other keys act as themselves. + return { id: r.user_id ? `user:${r.user_id}` : r.id, workspaceId: r.workspace_id, role: r.role }; } export async function revokeApiKey(db: Db, workspaceId: string, id: string): Promise { - await run(db, 'UPDATE api_keys SET revoked_at = ? WHERE id = ? AND workspace_id = ?', nowIso(), id, workspaceId); + const n = await run(db, 'UPDATE api_keys SET revoked_at = ? WHERE id = ? AND workspace_id = ? AND revoked_at IS NULL', nowIso(), id, workspaceId); + if (!n) throw new SubmitFleetError('not_found', 'key'); +} + +/** Long-lived keys a person made for agents and scripts (OAuth access tokens are not listed). */ +export async function listApiKeys(db: Db, workspaceId: string) { + return all(db, "SELECT id, role, label, created_at, last_used_at FROM api_keys WHERE workspace_id = ? AND revoked_at IS NULL AND label NOT LIKE 'oauth:%' ORDER BY created_at DESC", workspaceId); } export function requireOwner(actor: Actor): void { diff --git a/test/assisted.test.ts b/test/assisted.test.ts new file mode 100644 index 0000000..15f2744 --- /dev/null +++ b/test/assisted.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { fit } from '../src/assisted.js'; +import { all } from '../src/db.js'; +import { fakeAdapter, harness, type Harness } from './helpers.js'; + +let h: Harness; +afterEach(() => h?.cleanup()); + +async function product() { + const p = await h.fleet.productCreate(h.owner, { + url: 'https://acme.example/', + name: 'Acme', + tagline: 'The friendly to-do app for small teams', + description: 'Acme keeps a small team on one list. It syncs across devices, works offline and costs nothing for up to five people. Built by two developers who were tired of heavyweight project tools.', + categories: ['productivity', 'saas'], + contact_email: 'hello@acme.example', + logo_path: 'logo.png', + facts: { pricing_model: 'Freemium', x: '@acme' }, + }); + return h.fleet.productApproveFacts(h.owner, p.id); +} + +describe('bundled catalog', () => { + it('imports the curated catalog idempotently without enabling automation', async () => { + h = await harness([fakeAdapter()]); + const first = await h.fleet.loadBundledCatalog(); + expect(first.created + first.updated).toBe(339); + const again = await h.fleet.loadBundledCatalog(); + expect(again.created).toBe(0); + expect(again.updated).toBe(first.created + first.updated); + const stats = await h.fleet.catalogStats(); + expect(stats.total).toBe(first.created); + expect(stats.free).toBeGreaterThan(150); + expect(stats.by_channel.directory).toBeGreaterThan(50); + expect(stats.automated).toBe(0); + const cov = await h.fleet.coverageReport(); + expect(cov.release_qualifying).toBe(0); + }); +}); + +describe('assisted submissions', () => { + it('plans free directories and launch sites as assisted targets and never paid ones', async () => { + h = await harness([fakeAdapter()]); + await h.fleet.loadBundledCatalog(); + const p = await product(); + const plan = await h.fleet.campaignPlan(h.agent, { productId: p.id }); + expect(plan.campaign.status).toBe('draft'); + expect(plan.counts.assisted).toBeGreaterThan(50); + expect(plan.counts.auto).toBe(0); + const chosen = new Set(plan.targets.filter((t) => t.executable).map((t) => t.destination)); + const ds = await h.fleet.catalogSearch(undefined, 1000); + for (const d of ds.filter((x) => chosen.has(x.slug))) { + expect(['source_labeled_free', 'verified_free']).toContain(d.fee_status); + expect(['directory', 'product-launch-site']).toContain(d.channel); + } + const paid = ds.find((d) => d.fee_status === 'paid' && d.channel === 'directory'); + if (paid) expect(plan.targets.find((t) => t.destination === paid.slug)?.blockers).toContain('paid_route'); + + const strict = await h.fleet.campaignPlan(h.agent, { productId: p.id, mode: 'auto_only' }); + expect(strict.campaign.status).toBe('coverage_insufficient'); + expect(strict.counts.executable).toBe(0); + }); + + it('works an assisted submission end to end: kit, submitted, live; workers never touch it', async () => { + const adapter = fakeAdapter(); + h = await harness([adapter]); + await h.fleet.loadBundledCatalog(); + const p = await product(); + const directory = (await h.fleet.catalogSearch(undefined, 1000)).find((d) => d.channel === 'directory' && d.fee_status === 'source_labeled_free')!; + const plan = await h.fleet.campaignPlan(h.agent, { productId: p.id, destinations: [directory.slug] }); + expect(plan.counts).toMatchObject({ executable: 1, assisted: 1 }); + await h.fleet.approvalRequest(h.agent, plan.campaign.id); + await h.fleet.approvalGrant(h.owner, plan.campaign.id); + await h.fleet.campaignRun(h.owner, plan.campaign.id); + await h.fleet.worker().tick(10); + expect(adapter.calls.execute).toHaveLength(0); + + const [q] = await h.fleet.campaignQueue(h.owner, plan.campaign.id); + expect(q).toMatchObject({ state: 'needs_human', mode: 'assisted', reason_code: 'assisted', destination: directory.slug }); + const id = String(q!.id); + + const kit = await h.fleet.submissionKit(h.owner, id); + expect(kit.destination.submit_url).toBe(directory.submission_url); + const f = Object.fromEntries(kit.fields.map((x) => [x.key, x.value])); + expect(f.name).toBe('Acme'); + expect(f.url).toBe('https://acme.example/'); + expect(f.short_description!.length).toBeLessThanOrEqual(160); + expect(f['fact.pricing_model']).toBe('Freemium'); + expect(f.logo).toMatch(/\/p\/prd_.+\/logo$/); + + await expect(h.fleet.submissionRequeue(h.owner, id)).rejects.toMatchObject({ code: 'invalid_state' }); + await expect(h.fleet.submissionMark(h.agent, id, 'submitted')).rejects.toMatchObject({ code: 'owner_required' }); + await expect(h.fleet.submissionMark(h.owner, id, 'live', { listing_url: 'https://x.example' })).rejects.toMatchObject({ code: 'invalid_state' }); + expect(await h.fleet.submissionMark(h.owner, id, 'submitted', { note: 'free tier' })).toMatchObject({ ok: true, state: 'submitted' }); + await expect(h.fleet.submissionMark(h.owner, id, 'live')).rejects.toMatchObject({ code: 'invalid_request' }); + await expect(h.fleet.submissionMark(h.owner, id, 'live', { listing_url: 'javascript:alert(1)' })).rejects.toMatchObject({ code: 'invalid_request' }); + expect(await h.fleet.submissionMark(h.owner, id, 'live', { listing_url: 'https://dir.example/acme' })).toMatchObject({ state: 'published_verified' }); + + const r = await h.fleet.reportGet(h.owner, plan.campaign.id); + expect(r.outcomes).toEqual({ published_verified: 1 }); + expect(r.submissions[0]).toMatchObject({ listing_url: 'https://dir.example/acme' }); + const ev = await all<{ data_json: string }>(h.fleet.db, "SELECT data_json FROM intent_events WHERE intent_id = ? AND to_state = 'published_verified'", id); + expect(JSON.parse(ev[0]!.data_json).evidence.by).toBe('owner'); + }); + + it('skipping releases the duplicate slot so the destination can be planned again', async () => { + h = await harness([fakeAdapter()]); + await h.fleet.loadBundledCatalog(); + const p = await product(); + const directory = (await h.fleet.catalogSearch(undefined, 1000)).find((d) => d.channel === 'directory' && d.fee_status === 'source_labeled_free')!; + const plan = await h.fleet.campaignPlan(h.agent, { productId: p.id, destinations: [directory.slug] }); + await h.fleet.approvalRequest(h.agent, plan.campaign.id); + await h.fleet.approvalGrant(h.owner, plan.campaign.id); + await h.fleet.campaignRun(h.owner, plan.campaign.id); + const [q] = await h.fleet.campaignQueue(h.owner, plan.campaign.id); + expect(await h.fleet.submissionMark(h.owner, String(q!.id), 'skipped', { note: 'only paid listings now' })).toMatchObject({ state: 'failed' }); + const again = await h.fleet.campaignPlan(h.agent, { productId: p.id, destinations: [directory.slug] }); + expect(again.counts.executable).toBe(1); + }); + + it('fit() trims at a boundary', () => { + expect(fit('One. Two three four five six seven.', 12)).toBe('One. Two…'); + expect(fit('short', 10)).toBe('short'); + }); +}); diff --git a/test/http.test.ts b/test/http.test.ts index 6f132da..c12955c 100644 --- a/test/http.test.ts +++ b/test/http.test.ts @@ -52,7 +52,7 @@ describe('HTTP API', () => { const { call, agent } = await setup(); await seedDestinations(h.fleet, h.owner, 3); const p = await approvedProduct(h.fleet, h.owner); - const plan = (await (await call(agent, 'POST', '/api/v1/campaigns', { product_id: p.id })).json()) as { campaign: { id: string; status: string }; counts: { executable: number } }; + const plan = (await (await call(agent, 'POST', '/api/v1/campaigns', { product_id: p.id, mode: 'auto_only' })).json()) as { campaign: { id: string; status: string }; counts: { executable: number } }; expect(plan.campaign.status).toBe('coverage_insufficient'); expect(plan.counts.executable).toBe(3); const r = await call(agent, 'POST', `/api/v1/campaigns/${plan.campaign.id}/approval-request`); @@ -71,16 +71,8 @@ describe('HTTP API', () => { expect(bad.status).toBe(400); }); - it('serves the dashboard with a strict CSP, Copy Markdown and an honest clipboard fallback', async () => { + it('serves the installer as a shell script', async () => { const { app } = await setup(); - const page = await app.request('/app'); - expect(page.headers.get('content-security-policy')).toContain("script-src 'self'"); - const js = await (await app.request('/app/app.js')).text(); - expect(js).toContain('Copy Markdown'); - expect(js).toContain('navigator.clipboard.writeText'); - expect(js).toContain('Clipboard is unavailable'); - // Success is only reported after the write resolves. - expect(js.indexOf("await navigator.clipboard.writeText(text)")).toBeLessThan(js.indexOf("toast('Copied Markdown')")); const sh = await app.request('/install.sh'); expect(sh.headers.get('content-type')).toContain('shellscript'); expect(await sh.text()).toMatch(/^#!\/bin\/sh/); diff --git a/test/relevance.test.ts b/test/relevance.test.ts new file mode 100644 index 0000000..cd8de71 --- /dev/null +++ b/test/relevance.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import type { Destination } from '../src/catalog.js'; +import type { Product } from '../src/products.js'; +import { relevance } from '../src/relevance.js'; + +const p = (over: Partial): Product => ({ id: 'p', workspace_id: 'w', canonical_key: 'x', url: 'https://x.example', name: 'X', tagline: null, description: null, categories: [], contact_email: null, logo_path: null, facts: {}, destination_fields: {}, approved_at: 'x', approved_by: 'o', created_at: 'x', updated_at: 'x', ...over }); +const d = (categories: string[], eligibility = '', name = 'Dir'): Destination => ({ categories, eligibility, name } as Destination); +const iptv = p({ name: 'hdtilt', tagline: 'open-source IPTV player', description: 'A free IPTV player for your own playlist with a TV guide.' }); +const aiTool = p({ name: 'Summly', tagline: 'AI meeting notes', description: 'Uses an LLM to summarise calls.' }); + +describe('relevance', () => { + it('AI-only directories are a weak fit for a non-AI product and strong for an AI one', () => { + expect(relevance(iptv, d(['ai']))).toMatchObject({ fit: 'weak', reason: 'AI tools only' }); + expect(relevance(aiTool, d(['ai'])).fit).toBe('strong'); + expect(relevance(p({ categories: ['ai'] }), d(['ai'])).fit).toBe('strong'); + }); + it('general directories fit anyone; mixed niche + general stays general', () => { + expect(relevance(iptv, d(['business', 'saas'])).fit).toBe('general'); + expect(relevance(iptv, d(['ai', 'saas'])).fit).toBe('general'); + expect(relevance(iptv, d([])).fit).toBe('general'); + }); + it('platform-only listings need the platform', () => { + const stripe = d(['business', 'saas'], 'Requires an activated Stripe account that is not on the restricted list.', 'Stripe'); + expect(relevance(iptv, stripe)).toMatchObject({ fit: 'weak', reason: 'Stripe apps only' }); + expect(relevance(p({ description: 'Billing analytics for Stripe users.' }), stripe).fit).not.toBe('weak'); + }); +}); diff --git a/test/web.test.ts b/test/web.test.ts new file mode 100644 index 0000000..26c5e61 --- /dev/null +++ b/test/web.test.ts @@ -0,0 +1,243 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { resetRateLimits } from '../src/auth.js'; +import { buildApp } from '../src/http.js'; +import type { Mail } from '../src/mail.js'; +import { harness, type Harness } from './helpers.js'; + +let h: Harness; +let mails: Mail[]; +let app: ReturnType; +const ORIGIN = 'http://localhost'; + +beforeEach(async () => { + resetRateLimits(); + h = await harness(); + await h.fleet.loadBundledCatalog(); + mails = []; + app = buildApp(h.fleet, { mailer: async (m) => void mails.push(m), publicOrigin: ORIGIN }); +}); +afterEach(() => h?.cleanup()); + +const jar = new Map(); +function cookies(res: Response) { + for (const c of res.headers.getSetCookie?.() ?? []) { + const [kv] = c.split(';'); + const [k, v] = kv!.split('='); + if (v) jar.set(k!, v); + else jar.delete(k!); + } +} +async function req(method: string, path: string, body?: unknown, opts: { origin?: string | null; cookie?: boolean; form?: boolean } = {}) { + const headers: Record = {}; + if (opts.origin !== null) headers.origin = opts.origin ?? ORIGIN; + if (opts.cookie !== false && jar.size) headers.cookie = [...jar].map(([k, v]) => `${k}=${v}`).join('; '); + let payload: string | undefined; + if (body !== undefined) { + headers['content-type'] = opts.form ? 'application/x-www-form-urlencoded' : 'application/json'; + payload = opts.form ? new URLSearchParams(body as Record).toString() : JSON.stringify(body); + } + const res = await app.request(path, { method, headers, body: payload, redirect: 'manual' }); + cookies(res); + return res; +} +const tokenFrom = (m: Mail) => /#t=([\w-]+)/.exec(m.text)![1]!; + +describe('pages', () => { + it('landing shows the live catalog counts and strict CSP', async () => { + const res = await req('GET', '/'); + expect(res.status).toBe(200); + expect(res.headers.get('content-security-policy')).toContain("script-src 'self'"); + // no-referrer makes browsers send Origin: null on same-site form posts, which breaks the consent form. + expect(res.headers.get('referrer-policy')).toBe('same-origin'); + const html = await res.text(); + expect(html).not.toContain('{{'); + expect(html).toMatch(/339<\/b>destinations in the catalog/); + }); + + it('the app redirects to sign-in when signed out, and pages carry no inline script or style', async () => { + jar.clear(); + const res = await req('GET', '/app/products'); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('/login?next=%2Fapp%2Fproducts'); + for (const p of ['/', '/login', '/signup', '/auth/link', '/docs']) { + const html = await (await req('GET', p)).text(); + expect(html, p).not.toMatch(/ + + diff --git a/web/assets/app.css b/web/assets/app.css new file mode 100644 index 0000000..6cc4785 --- /dev/null +++ b/web/assets/app.css @@ -0,0 +1,88 @@ +body.app { display: grid; grid-template-columns: 236px 1fr; min-height: 100vh; } +.side { position: sticky; top: 0; height: 100vh; border-right: 1px solid var(--line); background: var(--card); padding: 18px 14px; display: flex; flex-direction: column; gap: 18px; } +.side nav { display: flex; flex-direction: column; gap: 2px; } +.side nav a, .tabbar a { color: var(--fg-2); text-decoration: none; font-weight: 550; padding: 9px 12px; border-radius: 8px; } +.side nav a:hover { background: var(--bg-2); } +.side nav a[aria-current=page] { background: var(--accent-soft); color: var(--accent); } +.side-foot { margin-top: auto; display: flex; flex-direction: column; gap: 8px; } +.who { font-size: 13px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.main { min-width: 0; } +.view { max-width: 980px; margin: 0 auto; padding: 28px 24px 96px; } +.tabbar { display: none; } +@media (max-width: 820px) { + body.app { grid-template-columns: 1fr; } + .side { display: none; } + .view { padding: 20px 16px 96px; } + .tabbar { display: flex; position: fixed; bottom: 0; left: 0; right: 0; background: var(--card); border-top: 1px solid var(--line); padding: 6px 6px calc(6px + env(safe-area-inset-bottom)); z-index: 20; } + .tabbar a { flex: 1; text-align: center; font-size: 13px; padding: 10px 4px; } + .tabbar a[aria-current=page] { color: var(--accent); } +} +.page-head { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; justify-content: space-between; margin-bottom: 22px; } +.page-head h1 { font-size: 26px; margin: 0; } +.page-head p { margin: 4px 0 0; color: var(--muted); } +.crumbs { font-size: 13px; margin-bottom: 8px; } +.crumbs a { color: var(--muted); text-decoration: none; } +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } +@media (max-width: 820px) { .grid2, .grid3 { grid-template-columns: 1fr; } } +.stack > * + * { margin-top: 14px; } +.section-title { font-size: 15px; font-weight: 700; margin: 26px 0 12px; } +.empty { text-align: center; padding: 44px 20px; } +.empty h2 { font-size: 20px; } +.empty p { color: var(--muted); max-width: 44ch; margin: 0 auto 18px; } + +.kpi b { display: block; font-size: 26px; letter-spacing: -0.02em; } +.kpi span { font-size: 13px; color: var(--muted); } + +.product-card { display: flex; gap: 14px; align-items: center; } +.logo { width: 48px; height: 48px; border-radius: 11px; border: 1px solid var(--line); background: var(--bg-2); object-fit: contain; flex: none; display: grid; place-items: center; font-weight: 700; color: var(--muted); overflow: hidden; } +.logo.lg { width: 72px; height: 72px; border-radius: 16px; font-size: 24px; } +.product-card .meta { min-width: 0; flex: 1; } +.product-card .meta b { display: block; } +.product-card .meta span { color: var(--muted); font-size: 13.5px; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +a.card-link { color: inherit; text-decoration: none; display: block; } +a.card-link:hover .card { border-color: var(--line-2); } + +.progress { height: 8px; border-radius: 99px; background: var(--bg-2); overflow: hidden; display: flex; } +.progress i { display: block; height: 100%; } +.progress i.live { background: var(--accent); } +.progress i.sub { background: var(--info); } +.progress i.skip { background: var(--line-2); } + +.list { border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); overflow: hidden; } +.list .item { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--line); } +.list .item:last-child { border-bottom: 0; } +.list .item .title { font-weight: 600; } +.list .item .sub { font-size: 13px; color: var(--muted); display: flex; gap: 8px; flex-wrap: wrap; } +.list .item.open { background: color-mix(in srgb, var(--accent-soft) 35%, var(--card)); } +.fav { width: 28px; height: 28px; border-radius: 7px; background: var(--bg-2); border: 1px solid var(--line); object-fit: contain; } +.kit { grid-column: 1 / -1; border-top: 1px dashed var(--line-2); padding-top: 14px; margin-top: 4px; } +.kit .field-row { display: grid; grid-template-columns: 150px 1fr auto; gap: 10px; align-items: start; padding: 8px 0; border-bottom: 1px solid var(--line); } +.kit .field-row:last-child { border-bottom: 0; } +.kit .field-row .k { font-size: 12.5px; font-weight: 650; color: var(--fg-2); padding-top: 6px; } +.kit .field-row .v { white-space: pre-wrap; word-break: break-word; font-size: 14px; padding-top: 5px; } +@media (max-width: 640px) { .kit .field-row { grid-template-columns: 1fr auto; } .kit .field-row .k { grid-column: 1 / -1; padding: 0; } } +.kit ol { padding-left: 20px; margin: 0 0 14px; color: var(--fg-2); font-size: 14px; } +.kit .facts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; } +.kit .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; align-items: center; } +.kit .actions input { max-width: 340px; } + +.filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; align-items: center; } +.chip { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line-2); background: var(--card); padding: 6px 11px; border-radius: 99px; font-size: 13px; font-weight: 550; cursor: pointer; user-select: none; } +.chip input { accent-color: var(--accent); margin: 0; } +.chip:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); } +.dest-row { display: grid; grid-template-columns: auto auto 1fr auto; gap: 12px; align-items: center; padding: 10px 14px; border-bottom: 1px solid var(--line); } +.dest-row:last-child { border-bottom: 0; } +.dest-row .d { min-width: 0; } +.dest-row .d b { display: block; } +.dest-row .d span { font-size: 13px; color: var(--muted); display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; } +.dest-row .nums { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; } +@media (max-width: 640px) { .dest-row { grid-template-columns: auto auto 1fr; } .dest-row .nums { grid-column: 3; justify-content: flex-start; } } +.sticky-bar { position: sticky; bottom: 0; background: color-mix(in srgb, var(--bg) 92%, transparent); backdrop-filter: blur(8px); border-top: 1px solid var(--line); padding: 12px 0; margin-top: 16px; display: flex; gap: 10px; align-items: center; justify-content: space-between; flex-wrap: wrap; z-index: 5; } +@media (max-width: 820px) { .sticky-bar { bottom: 58px; } } +.token { font-family: var(--mono); font-size: 13px; background: var(--bg-2); border: 1px solid var(--line); border-radius: 8px; padding: 10px; word-break: break-all; } +#toast { position: fixed; left: 50%; transform: translateX(-50%); bottom: 24px; background: var(--fg); color: var(--bg); padding: 10px 16px; border-radius: 10px; font-size: 14px; display: none; z-index: 50; max-width: calc(100vw - 32px); } +@media (max-width: 820px) { #toast { bottom: 80px; } } +.skeleton { color: var(--muted); padding: 40px 0; text-align: center; } +table.plain { width: 100%; border-collapse: collapse; font-size: 14px; } +table.plain td, table.plain th { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--line); } diff --git a/web/assets/app.js b/web/assets/app.js new file mode 100644 index 0000000..fb67f5e --- /dev/null +++ b/web/assets/app.js @@ -0,0 +1,703 @@ +import { createPasskey, passkeysSupported } from './webauthn.js'; + +// ── Tiny DOM helpers: every value goes in as text, never as HTML ── +const $ = (s, root = document) => root.querySelector(s); +function h(tag, attrs = {}, ...kids) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v == null || v === false) continue; + if (k === 'class') el.className = v; + else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); + else if (k === 'value') el.value = v; + else if (v === true) el.setAttribute(k, ''); + else el.setAttribute(k, String(v)); + } + for (const kid of kids.flat(Infinity)) if (kid != null && kid !== false) el.append(kid instanceof Node ? kid : String(kid)); + return el; +} +const fmt = (n) => (n == null ? '–' : Intl.NumberFormat(undefined, { notation: n >= 10000 ? 'compact' : 'standard' }).format(n)); +const when = (iso) => (iso ? new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : ''); + +function toast(msg) { + const t = $('#toast'); + t.textContent = msg; + t.style.display = 'block'; + clearTimeout(t._t); + t._t = setTimeout(() => (t.style.display = 'none'), 3200); +} + +async function api(path, opts = {}) { + const res = await fetch(path.startsWith('/api') ? path : `/api/v1${path}`, { + credentials: 'same-origin', + ...opts, + headers: { ...(opts.body && !(opts.body instanceof Blob) ? { 'content-type': 'application/json' } : {}), ...(opts.headers || {}) }, + }); + if (res.status === 401) { + location.assign(`/login?next=${encodeURIComponent(location.pathname)}`); + throw new Error('signed out'); + } + const type = res.headers.get('content-type') || ''; + const data = type.includes('json') ? await res.json() : await res.text(); + if (!res.ok) throw new Error((data && (data.message || data.error)) || `HTTP ${res.status}`); + return data; +} +const post = (p, body) => api(p, { method: 'POST', body: JSON.stringify(body ?? {}) }); + +async function copy(text, label = 'Copied') { + try { + await navigator.clipboard.writeText(text); + toast(label); + return true; + } catch { + // Clipboard blocked (permissions, insecure context): show the text selected instead. + const ta = h('textarea', { class: 'token', readonly: true, rows: 4 }); + ta.value = text; + const dlg = h('div', { class: 'notice warn' }, 'Copy is blocked here. The text is selected: copy it manually.', ta); + $('#view').prepend(dlg); + ta.focus(); + ta.select(); + return false; + } +} + +const STATE = { + needs_human: ['Your turn', 'amber'], + queued: ['Queued', 'blue'], + executing: ['Submitting', 'blue'], + uncertain: ['Checking', 'amber'], + submitted: ['Submitted', 'blue'], + scheduled: ['Scheduled', 'blue'], + under_review: ['In review', 'blue'], + published_verified: ['Live', 'green'], + rejected: ['Rejected', 'red'], + removal_observed: ['Removed', 'red'], + failed: ['Skipped', ''], + canceled: ['Canceled', ''], +}; +const pill = (state, reason) => { + const [label, color] = STATE[state] || [state, '']; + return h('span', { class: `pill ${color}`, title: reason || '' }, state === 'failed' && reason && !reason.startsWith('skipped') ? 'Failed' : label); +}; +const CHANNELS = [ + ['directory', 'Directories'], + ['product-launch-site', 'Launch sites'], + ['citation', 'Business citations'], + ['community', 'Communities'], + ['newsletter', 'Newsletters'], + ['subreddit', 'Subreddits'], + ['marketplace', 'Marketplaces'], +]; +const channelName = (c) => (CHANNELS.find((x) => x[0] === c) || [c, c])[1]; +const PRICING = { source_labeled_free: ['Free', 'green'], verified_free: ['Free (verified)', 'green'], paid: ['Paid', 'red'], conditional_free: ['Conditions', 'amber'], unknown: ['Price unknown', ''] }; +const pricePill = (p) => h('span', { class: `pill ${PRICING[p]?.[1] || ''}` }, PRICING[p]?.[0] || p); +const logoEl = (p, cls = 'logo') => + p.logo_path ? h('img', { class: cls, src: `/p/${p.id}/logo?v=${encodeURIComponent(p.updated_at)}`, alt: '' }) : h('div', { class: cls }, (p.name || p.url || '?').replace(/^https?:\/\/(www\.)?/, '').charAt(0).toUpperCase()); + +function pageHead(title, sub, ...actions) { + return h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, title), sub ? h('p', {}, sub) : null), actions.length ? h('div', { class: 'btn-row' }, actions) : null); +} +const link = (href, text, cls) => h('a', { href, class: cls, 'data-link': true }, text); + +// ── Router ── +const routes = [ + [/^\/app\/?$/, home], + [/^\/app\/products\/?$/, productsPage], + [/^\/app\/products\/new$/, newProduct], + [/^\/app\/products\/([\w-]+)\/launch$/, launchPage], + [/^\/app\/products\/([\w-]+)$/, productPage], + [/^\/app\/campaigns\/?$/, campaignsPage], + [/^\/app\/campaigns\/([\w-]+)$/, campaignPage], + [/^\/app\/destinations\/?$/, destinationsPage], + [/^\/app\/settings\/?$/, settingsPage], +]; +let me = null; +async function render() { + const path = location.pathname; + const view = $('#view'); + const section = path.split('/')[2] || 'home'; + for (const a of document.querySelectorAll('[data-nav]')) { + if (a.dataset.nav === section) a.setAttribute('aria-current', 'page'); + else a.removeAttribute('aria-current'); + } + for (const [re, fn] of routes) { + const m = path.match(re); + if (m) { + view.replaceChildren(h('div', { class: 'skeleton' }, 'Loading…')); + try { + const node = await fn(...m.slice(1)); + view.replaceChildren(node); + } catch (e) { + if (e.message !== 'signed out') view.replaceChildren(h('div', { class: 'notice bad' }, e.message)); + } + window.scrollTo(0, 0); + return; + } + } + view.replaceChildren(h('div', { class: 'empty' }, h('h2', {}, 'Not found'), link('/app', 'Back to overview', 'btn'))); +} +function go(path) { + history.pushState(null, '', path); + render(); +} +document.addEventListener('click', (e) => { + const a = e.target.closest('a[href^="/app"]'); + if (a && !e.metaKey && !e.ctrlKey && !e.shiftKey && a.target !== '_blank') { + e.preventDefault(); + go(a.getAttribute('href')); + } +}); +window.addEventListener('popstate', render); + +// ── Overview ── +async function home() { + const [products, campaigns, queue] = await Promise.all([api('/products'), api('/campaigns'), api('/human-queue')]); + const welcome = new URLSearchParams(location.search).get('welcome'); + const root = h('div'); + if (welcome) { + history.replaceState(null, '', '/app'); + root.append( + h('div', { class: 'notice ok' }, 'Your account is ready. ', passkeysSupported() ? h('span', {}, 'Add a passkey in ', link('/app/settings', 'Settings'), ' for one-tap sign-in next time.') : null), + ); + } + if (!products.length) { + root.append( + h('div', { class: 'card empty' }, + h('h2', {}, 'Add your first product'), + h('p', {}, 'Paste your product URL. SubmitFleet drafts the listing details from your page, you review them once, then pick where to launch.'), + link('/app/products/new', 'Add a product', 'btn primary lg')), + ); + return root; + } + const waiting = queue.filter((q) => q.state === 'needs_human').length; + const statuses = await Promise.all(campaigns.slice(0, 8).map((c) => api(`/campaigns/${c.id}/status`))); + const live = statuses.reduce((n, s) => n + (s.intents.published_verified || 0), 0); + const submitted = statuses.reduce((n, s) => n + (s.intents.submitted || 0) + (s.intents.scheduled || 0) + (s.intents.under_review || 0), 0); + root.append( + pageHead('Overview', null, link('/app/products/new', 'Add product', 'btn')), + h('div', { class: 'grid3' }, + h('div', { class: 'card kpi' }, h('b', {}, fmt(waiting)), h('span', {}, 'waiting for you')), + h('div', { class: 'card kpi' }, h('b', {}, fmt(submitted)), h('span', {}, 'submitted, awaiting review')), + h('div', { class: 'card kpi' }, h('b', {}, fmt(live)), h('span', {}, 'live listings'))), + h('div', { class: 'section-title' }, 'Products'), + h('div', { class: 'grid2' }, products.map(productCard)), + ); + if (campaigns.length) { + root.append(h('div', { class: 'section-title' }, 'Recent campaigns'), campaignList(campaigns.slice(0, 8), statuses, products)); + } + return root; +} + +function productCard(p) { + return h('a', { href: `/app/products/${p.id}`, class: 'card-link' }, + h('div', { class: 'card product-card' }, logoEl(p), + h('div', { class: 'meta' }, h('b', {}, p.name || p.url), h('span', {}, p.tagline || p.url)), + p.approved_at ? h('span', { class: 'pill green' }, 'Ready') : h('span', { class: 'pill amber' }, 'Needs review'))); +} + +function campaignList(campaigns, statuses, products) { + return sizeBars(h('div', { class: 'list' }, campaigns.map((c, i) => { + const st = statuses[i]?.intents || {}; + const total = Object.values(st).reduce((a, b) => a + b, 0) || c.executable_count; + const live = st.published_verified || 0; + const sub = (st.submitted || 0) + (st.scheduled || 0) + (st.under_review || 0); + const skip = (st.failed || 0) + (st.rejected || 0) + (st.canceled || 0); + const todo = st.needs_human || 0; + const p = products.find((x) => x.id === c.product_id); + return h('a', { href: `/app/campaigns/${c.id}`, class: 'card-link' }, + h('div', { class: 'item' }, p ? logoEl(p, 'fav') : h('span'), + h('div', {}, + h('div', { class: 'title' }, `${p?.name || 'Campaign'} · ${fmt(total)} destinations`), + h('div', { class: 'progress', 'data-total': total }, h('i', { class: 'live', 'data-w': live }), h('i', { class: 'sub', 'data-w': sub }), h('i', { class: 'skip', 'data-w': skip })), + h('div', { class: 'sub' }, h('span', {}, `${todo} to do · ${sub} submitted · ${live} live`), h('span', {}, `· started ${when(c.created_at)}`))), + h('span', { class: 'pill' }, c.status.replace(/_/g, ' ')))); + }))); +} +// Width via CSSOM (allowed by CSP), not inline style attributes. +function sizeBars(node) { + queueMicrotask(() => { + for (const bar of document.querySelectorAll('.progress')) { + const parts = [...bar.children]; + const total = Number(bar.dataset.total) || parts.reduce((n, p) => n + Number(p.dataset.w || 0), 0); + for (const p of parts) p.style.width = total ? `${(100 * Number(p.dataset.w || 0)) / total}%` : '0'; + } + }); + return node; +} + +// ── Products ── +async function productsPage() { + const products = await api('/products'); + return h('div', {}, pageHead('Products', 'Everything you launch, each with one approved profile.', link('/app/products/new', 'Add product', 'btn primary')), + products.length ? h('div', { class: 'grid2' }, products.map(productCard)) : h('div', { class: 'card empty' }, h('p', {}, 'No products yet.'), link('/app/products/new', 'Add a product', 'btn primary'))); +} + +function productForm(p, onSave, submitLabel) { + const f = (name, label, el, hint) => h('div', { class: 'field' }, h('label', { for: name }, label), el, hint ? h('div', { class: 'hint' }, hint) : null); + const input = (name, value, type = 'text', extra = {}) => h('input', { id: name, name, type, value: value ?? '', ...extra }); + const facts = p.facts || {}; + const desc = h('textarea', { id: 'description', name: 'description', rows: 6, maxlength: 5000 }); + desc.value = p.description || ''; + const pricing = h('select', { id: 'pricing_model', name: 'pricing_model' }, ['', 'Free', 'Freemium', 'Paid', 'Free trial', 'Open source'].map((o) => h('option', { value: o }, o || 'Choose…'))); + pricing.value = facts.pricing_model || ''; + const form = h('form', { class: 'card', novalidate: true }, + f('url', 'Website', input('url', p.url, 'url', { required: true, readonly: p.id ? true : null })), + h('div', { class: 'grid2' }, + f('name', 'Product name', input('name', p.name, 'text', { maxlength: 80, required: true })), + f('tagline', 'Tagline', input('tagline', p.tagline, 'text', { maxlength: 120 }), 'One line. Most forms cut at 60 characters.')), + f('description', 'Description', desc, 'Plain text, a few sentences. A 160-character version is made for you.'), + h('div', { class: 'grid2' }, + f('categories', 'Categories', input('categories', (p.categories || []).join(', '), 'text', { placeholder: 'productivity, saas, ai' }), 'Comma separated. Used to match destinations.'), + f('contact_email', 'Contact email', input('contact_email', p.contact_email, 'email'), 'Shown on listings that ask for one.')), + h('div', { class: 'grid3' }, + f('pricing_model', 'Pricing', pricing), + f('x', 'X / Twitter', input('x', facts.x, 'text', { placeholder: '@handle' })), + f('github', 'GitHub', input('github', facts.github, 'url', { placeholder: 'https://github.com/…' }))), + f('founder', 'Founder or maker', input('founder', facts.founder, 'text', { maxlength: 80 })), + h('div', { id: 'form-msg' }), + h('button', { class: 'btn primary', type: 'submit' }, submitLabel)); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const v = (k) => form.elements[k].value.trim(); + if (!v('url') || !v('name')) return form.querySelector('#form-msg').replaceChildren(h('div', { class: 'notice bad' }, 'Website and product name are required.')); + const body = { + url: v('url'), + name: v('name'), + tagline: v('tagline') || null, + description: v('description') || null, + categories: v('categories').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean).slice(0, 20), + contact_email: v('contact_email') || null, + facts: Object.fromEntries(['pricing_model', 'x', 'github', 'founder'].map((k) => [k, v(k)]).filter(([, x]) => x)), + }; + const btn = form.querySelector('button[type=submit]'); + btn.disabled = true; + try { + await onSave(body); + } catch (err) { + form.querySelector('#form-msg').replaceChildren(h('div', { class: 'notice bad' }, err.message)); + btn.disabled = false; + } + }); + return form; +} + +async function newProduct() { + const root = h('div', {}, h('div', { class: 'crumbs' }, link('/app/products', '← Products')), pageHead('Add a product', 'Start from your URL. Nothing is sent anywhere until you approve it.')); + const urlInput = h('input', { type: 'url', placeholder: 'https://your-product.com', id: 'start-url', required: true }); + const msg = h('div'); + const holder = h('div'); + let icon = null; + const fetchBtn = h('button', { class: 'btn primary', type: 'submit' }, 'Fetch details'); + const start = h('form', { class: 'card stack' }, h('label', { for: 'start-url' }, 'Product URL'), h('div', { class: 'btn-row' }, urlInput, fetchBtn), msg, + h('button', { class: 'btn ghost sm', type: 'button', onclick: () => showForm({ url: urlInput.value.trim() }) }, 'Skip and fill in by hand')); + const showForm = (draft) => { + const iconBox = icon ? h('div', { class: 'card product-card stack' }, h('img', { class: 'logo lg', src: icon, alt: '' }), h('label', { class: 'check' }, h('input', { type: 'checkbox', id: 'use-icon', checked: true }), 'Use this as the logo')) : null; + holder.replaceChildren( + h('div', { class: 'section-title' }, 'Review the details'), + iconBox, + productForm(draft, async (body) => { + const p = await post('/products', body); + if (icon && document.getElementById('use-icon')?.checked) await post(`/products/${p.id}/logo/from-url`, { url: icon }).catch(() => toast('Logo could not be fetched; upload one on the next page.')); + go(`/app/products/${p.id}`); + }, 'Save product'), + ); + start.hidden = true; + }; + start.addEventListener('submit', async (e) => { + e.preventDefault(); + const url = urlInput.value.trim(); + if (!/^https?:\/\//.test(url)) return msg.replaceChildren(h('div', { class: 'notice bad' }, 'Enter the full URL, starting with https://')); + fetchBtn.disabled = true; + fetchBtn.textContent = 'Reading your page…'; + try { + const facts = await post('/products/extract', { url }); + icon = facts.icon_url; + showForm({ url, name: facts.name, tagline: facts.tagline, description: facts.description, facts: { x: facts.x, github: facts.github } }); + } catch (err) { + msg.replaceChildren(h('div', { class: 'notice warn' }, `Could not read that page (${err.message}). Fill in the details by hand.`)); + showForm({ url }); + } + }); + root.append(start, holder); + return root; +} + +async function productPage(id) { + const [p, campaigns] = await Promise.all([api(`/products/${id}`), api('/campaigns')]); + const mine = campaigns.filter((c) => c.product_id === id); + const file = h('input', { type: 'file', accept: 'image/png,image/jpeg,image/webp', class: 'sr', id: 'logo-file' }); + file.addEventListener('change', async () => { + const f = file.files[0]; + if (!f) return; + if (f.size > 1_000_000) return toast('Logo must be under 1 MB'); + try { + await api(`/products/${id}/logo`, { method: 'PUT', body: f, headers: { 'content-type': f.type } }); + toast('Logo updated'); + render(); + } catch (e) { + toast(e.message); + } + }); + const approve = async () => { + await post(`/products/${id}/approve-facts`); + toast('Approved'); + render(); + }; + const root = h('div', {}, + h('div', { class: 'crumbs' }, link('/app/products', '← Products')), + h('div', { class: 'page-head' }, + h('div', { class: 'product-card' }, logoEl(p, 'logo lg'), h('div', { class: 'meta' }, h('h1', {}, p.name || p.url), h('a', { href: p.url, target: '_blank', rel: 'noopener noreferrer' }, p.url))), + h('div', { class: 'btn-row' }, h('label', { class: 'btn', for: 'logo-file' }, p.logo_path ? 'Change logo' : 'Upload logo'), file, + p.approved_at ? link(`/app/products/${id}/launch`, 'Launch it', 'btn primary') : null)), + ); + if (!p.approved_at) { + root.append(h('div', { class: 'notice warn' }, h('b', {}, 'Review and approve these details. '), 'Every submission uses exactly what you approve here, so check spelling, claims and the description first. ', + h('div', { class: 'btn-row' }, h('button', { class: 'btn primary sm', type: 'button', onclick: approve }, 'Approve details')))); + } else { + root.append(h('div', { class: 'notice ok' }, `Approved ${when(p.approved_at)}. Editing anything asks you to approve again.`)); + } + root.append( + productForm(p, async (body) => { + const { url: _u, ...patch } = body; + await api(`/products/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }); + toast('Saved. Approve the details again to launch.'); + render(); + }, 'Save changes'), + ); + if (mine.length) { + const statuses = await Promise.all(mine.map((c) => api(`/campaigns/${c.id}/status`))); + root.append(h('div', { class: 'section-title' }, 'Campaigns'), campaignList(mine, statuses, [p])); + } + return root; +} + +// ── Launch planner ── +async function launchPage(id) { + const p = await api(`/products/${id}`); + if (!p.approved_at) { + go(`/app/products/${id}`); + return h('div'); + } + const chosen = new Set(['directory', 'product-launch-site']); + let includeUnknown = false; + let q = ''; + const selected = new Map(); + let list = []; + const listEl = h('div', { class: 'list' }); + const countEl = h('b'); + const startBtn = h('button', { class: 'btn primary lg', type: 'button' }, 'Prepare submissions'); + const filters = h('div', { class: 'filters' }, CHANNELS.map(([k, label]) => { + const cb = h('input', { type: 'checkbox', checked: chosen.has(k) }); + cb.addEventListener('change', () => (cb.checked ? chosen.add(k) : chosen.delete(k), load())); + return h('label', { class: 'chip' }, cb, label); + })); + const unknown = h('input', { type: 'checkbox' }); + unknown.addEventListener('change', () => ((includeUnknown = unknown.checked), load())); + const search = h('input', { type: 'search', placeholder: 'Search destinations' }); + search.addEventListener('input', () => { + q = search.value.trim().toLowerCase(); + draw(); + }); + const updateCount = () => { + countEl.textContent = `${selected.size} selected`; + startBtn.disabled = selected.size === 0; + }; + const draw = () => { + const shown = list.filter((d) => !q || `${d.name} ${d.description || ''} ${d.categories.join(' ')}`.toLowerCase().includes(q)); + listEl.replaceChildren(...(shown.length ? shown.map(destRow) : [h('div', { class: 'empty muted' }, 'No destinations match these filters.')])); + updateCount(); + }; + const destRow = (d) => { + const cb = h('input', { type: 'checkbox', checked: selected.has(d.slug) }); + cb.addEventListener('change', () => (cb.checked ? selected.set(d.slug, d) : selected.delete(d.slug), updateCount())); + return h('label', { class: 'dest-row' }, cb, h('img', { class: 'fav', src: `/d/${d.slug}/icon`, alt: '', loading: 'lazy' }), + h('div', { class: 'd' }, h('b', {}, d.name), h('span', {}, d.description || d.canonical_domain)), + h('div', { class: 'nums' }, d.fit === 'strong' ? h('span', { class: 'pill green' }, 'Good fit') : d.fit === 'weak' ? h('span', { class: 'pill amber', title: 'Unchecked by default' }, d.reason) : null, + h('span', { class: 'pill' }, channelName(d.channel)), pricePill(d.fee_status), d.domain_rating != null ? h('span', { class: 'pill' }, `DR ${d.domain_rating}`) : null)); + }; + const load = async () => { + const pricing = ['source_labeled_free', 'verified_free', ...(includeUnknown ? ['unknown'] : [])]; + list = chosen.size ? await api(`/catalog?product=${id}&channel=${[...chosen].join(',')}&pricing=${pricing.join(',')}`) : []; + selected.clear(); + for (const d of list) if (d.fit !== 'weak') selected.set(d.slug, d); + draw(); + }; + const msg = h('div'); + startBtn.addEventListener('click', async () => { + startBtn.disabled = true; + startBtn.textContent = 'Preparing…'; + try { + const plan = await post('/campaigns', { product_id: id, destinations: [...selected.keys()], channels: [...chosen], include_unknown_pricing: includeUnknown }); + if (plan.counts.executable === 0) { + const dupes = plan.targets.filter((t) => t.blockers.some((b) => b.startsWith('duplicate'))).length; + throw new Error(dupes ? `Every selected destination already has a submission for this product (${dupes}).` : 'None of the selected destinations can take this product.'); + } + const req = await post(`/campaigns/${plan.campaign.id}/approval-request`); + await post(`/campaigns/${plan.campaign.id}/approve`, { scope_hash: req.scope_hash }); + await post(`/campaigns/${plan.campaign.id}/run`); + go(`/app/campaigns/${plan.campaign.id}`); + } catch (e) { + msg.replaceChildren(h('div', { class: 'notice bad' }, e.message)); + startBtn.disabled = false; + startBtn.textContent = 'Prepare submissions'; + } + }); + await load(); + return h('div', {}, + h('div', { class: 'crumbs' }, link(`/app/products/${id}`, `← ${p.name}`)), + pageHead(`Launch ${p.name}`, 'Choose where to submit. Free listings only, and anywhere this product was already submitted is left out.'), + filters, + h('div', { class: 'filters' }, h('label', { class: 'chip' }, unknown, 'Include unknown pricing'), search, + h('button', { class: 'btn sm', type: 'button', onclick: () => (list.forEach((d) => selected.set(d.slug, d)), draw()) }, 'Select all'), + h('button', { class: 'btn sm ghost', type: 'button', onclick: () => (selected.clear(), draw()) }, 'Clear')), + msg, + listEl, + h('div', { class: 'sticky-bar' }, h('div', {}, countEl, h('div', { class: 'small muted' }, 'You approve this exact list by starting. Listing fees: $0.')), startBtn)); +} + +// ── Campaigns ── +async function campaignsPage() { + const [campaigns, products] = await Promise.all([api('/campaigns'), api('/products')]); + if (!campaigns.length) return h('div', {}, pageHead('Campaigns'), h('div', { class: 'card empty' }, h('p', {}, 'No campaigns yet. Open a product and press Launch it.'), link('/app/products', 'Go to products', 'btn'))); + const statuses = await Promise.all(campaigns.map((c) => api(`/campaigns/${c.id}/status`))); + return h('div', {}, pageHead('Campaigns'), campaignList(campaigns, statuses, products)); +} + +async function campaignPage(id) { + const [status, items, products] = await Promise.all([api(`/campaigns/${id}/status`), api(`/campaigns/${id}/queue`), api('/products')]); + const c = status.campaign; + const p = products.find((x) => x.id === c.product_id) || { name: 'Product' }; + const groups = { + todo: (i) => i.state === 'needs_human' || i.state === 'queued' || i.state === 'executing' || i.state === 'uncertain', + submitted: (i) => ['submitted', 'scheduled', 'under_review'].includes(i.state), + live: (i) => i.state === 'published_verified', + closed: (i) => ['failed', 'rejected', 'canceled', 'removal_observed'].includes(i.state), + }; + let tab = items.some(groups.todo) ? 'todo' : 'submitted'; + let open = null; + const listEl = h('div', { class: 'list' }); + const tabs = h('div', { class: 'tabs' }); + const counts = () => Object.fromEntries(Object.entries(groups).map(([k, f]) => [k, items.filter(f).length])); + const drawTabs = () => { + const n = counts(); + tabs.replaceChildren(...[['todo', 'To do'], ['submitted', 'Submitted'], ['live', 'Live'], ['closed', 'Skipped']].map(([k, label]) => + h('button', { type: 'button', 'aria-selected': String(tab === k), onclick: () => ((tab = k), (open = null), draw()) }, `${label} ${n[k]}`))); + }; + const progress = h('div', { class: 'progress' }); + const drawProgress = () => { + const n = counts(); + progress.dataset.total = items.length; + progress.replaceChildren(h('i', { class: 'live', 'data-w': n.live }), h('i', { class: 'sub', 'data-w': n.submitted }), h('i', { class: 'skip', 'data-w': n.closed })); + sizeBars(progress); + }; + const draw = () => { + drawTabs(); + drawProgress(); + const shown = items.filter(groups[tab]); + listEl.replaceChildren(...(shown.length ? shown.map(row) : [h('div', { class: 'empty muted' }, tab === 'todo' ? 'Nothing left to do here. Nice.' : 'Nothing here yet.')])); + if (open) { + const el = listEl.querySelector(`[data-id="${open}"]`); + el?.scrollIntoView({ block: 'nearest' }); + } + }; + const next = (afterId) => { + const shown = items.filter(groups.todo); + const idx = shown.findIndex((x) => x.id === afterId); + open = shown[idx + 1]?.id || shown[0]?.id || null; + }; + const mark = async (item, action, extra = {}) => { + const r = await post(`/submissions/${item.id}/mark`, { action, ...extra }); + item.state = r.state; + if (extra.listing_url) item.listing_url = extra.listing_url; + toast({ submitted: 'Marked submitted', live: 'Marked live', skipped: 'Skipped', rejected: 'Marked rejected' }[action]); + if (action === 'submitted' || action === 'skipped') next(item.id); + else open = null; + draw(); + }; + const row = (i) => { + const el = h('div', { class: `item${open === i.id ? ' open' : ''}`, 'data-id': i.id }, + h('img', { class: 'fav', src: `/d/${i.destination}/icon`, alt: '', loading: 'lazy' }), + h('div', {}, h('div', { class: 'title' }, i.name), + h('div', { class: 'sub' }, h('span', {}, channelName(i.channel)), i.domain_rating != null ? h('span', {}, `DR ${i.domain_rating}`) : null, i.monthly_traffic ? h('span', {}, `${fmt(i.monthly_traffic)} visits/mo`) : null, + i.fit === 'strong' ? h('span', { class: 'pill green' }, 'Good fit') : i.fit === 'weak' ? h('span', { class: 'pill amber' }, i.fit_reason) : null, + i.mode === 'auto' ? h('span', { class: 'pill blue' }, 'automatic') : null, i.listing_url ? h('a', { href: i.listing_url, target: '_blank', rel: 'noopener noreferrer' }, 'listing') : null)), + h('div', { class: 'btn-row' }, pill(i.state, i.reason_code), h('button', { class: 'btn sm', type: 'button', onclick: () => ((open = open === i.id ? null : i.id), draw()) }, open === i.id ? 'Close' : 'Open'))); + if (open === i.id) el.append(kitPanel(i)); + return el; + }; + const kitPanel = (i) => { + const box = h('div', { class: 'kit' }, h('div', { class: 'muted small' }, 'Loading…')); + api(`/submissions/${i.id}/kit`).then((kit) => { + const d = kit.destination; + const urlInput = h('input', { type: 'url', placeholder: 'Listing or confirmation URL (optional)' }); + const facts = h('div', { class: 'facts' }, pricePill(d.pricing), d.link_type ? h('span', { class: 'pill' }, `${d.link_type} link`) : null, d.audience ? h('span', { class: 'pill', title: d.audience }, 'Audience') : null); + const actions = h('div', { class: 'actions' }); + if (i.state === 'needs_human' && i.mode !== 'auto') { + actions.append(urlInput, + h('button', { class: 'btn primary', type: 'button', onclick: () => mark(i, 'submitted', { listing_url: urlInput.value.trim() || null }) }, 'Mark submitted'), + h('button', { class: 'btn ghost', type: 'button', onclick: () => { + const why = prompt('Why skip it? (optional, e.g. "only paid listings")'); + if (why !== null) mark(i, 'skipped', { note: why || null }); + } }, 'Skip')); + } else if (['submitted', 'scheduled', 'under_review'].includes(i.state)) { + urlInput.placeholder = 'Public listing URL'; + if (i.listing_url) urlInput.value = i.listing_url; + actions.append(urlInput, + h('button', { class: 'btn primary', type: 'button', onclick: () => (urlInput.value.trim() ? mark(i, 'live', { listing_url: urlInput.value.trim() }) : toast('Paste the public listing URL first')) }, 'Mark live'), + h('button', { class: 'btn ghost', type: 'button', onclick: () => mark(i, 'rejected') }, 'They rejected it')); + } else if (i.state === 'needs_human') { + actions.append(h('span', { class: 'muted small' }, `Waiting on: ${i.reason_code}`)); + } + box.replaceChildren( + d.eligibility ? h('div', { class: 'notice info small' }, h('b', {}, 'Who they accept: '), d.eligibility) : null, + d.pricing_note ? h('div', { class: 'notice warn small' }, h('b', {}, 'Pricing: '), d.pricing_note) : null, + facts, + h('ol', {}, kit.steps.map((s) => h('li', {}, s))), + d.submit_url ? h('div', { class: 'btn-row' }, h('a', { class: 'btn primary', href: d.submit_url, target: '_blank', rel: 'noopener noreferrer' }, `Open ${d.name} ↗`), + h('button', { class: 'btn', type: 'button', onclick: () => copy(kit.fields.map((f) => `${f.label}: ${f.value}`).join('\n\n'), 'All fields copied') }, 'Copy all fields')) : null, + h('div', {}, kit.fields.map((f) => h('div', { class: 'field-row' }, h('div', { class: 'k' }, f.label, f.max ? h('span', { class: 'muted' }, ` (≤${f.max})`) : null), h('div', { class: 'v' }, f.value), + h('button', { class: 'btn sm', type: 'button', onclick: () => copy(f.value, `${f.label} copied`) }, 'Copy')))), + actions); + }).catch((e) => box.replaceChildren(h('div', { class: 'notice bad' }, e.message))); + return box; + }; + const exportBtn = (fmtName, label) => h('button', { class: 'btn sm', type: 'button', onclick: async () => { + const text = await api(`/reports/${id}?format=${fmtName}`); + const blob = new Blob([typeof text === 'string' ? text : JSON.stringify(text, null, 2)], { type: 'text/plain' }); + const a = h('a', { href: URL.createObjectURL(blob), download: `submitfleet-${id}.${fmtName === 'markdown' ? 'md' : fmtName}` }); + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + } }, label); + open = items.find(groups.todo)?.id || null; + draw(); + return h('div', {}, + h('div', { class: 'crumbs' }, link('/app/campaigns', '← Campaigns')), + pageHead(`${p.name} launch`, `${items.length} destinations · started ${when(c.created_at)}`, + h('button', { class: 'btn sm', type: 'button', onclick: async () => copy(await api(`/reports/${id}?format=markdown`), 'Report copied as Markdown') }, 'Copy Markdown'), + exportBtn('csv', 'CSV'), exportBtn('json', 'JSON')), + progress, h('div', { class: 'section-title' }), tabs, listEl); +} + +// ── Destinations ── +async function destinationsPage() { + const stats = await api('/catalog-stats'); + const chosen = new Set(); + let q = ''; + const listEl = h('div', { class: 'list' }); + const load = async () => { + const qs = new URLSearchParams({ limit: '500' }); + if (chosen.size) qs.set('channel', [...chosen].join(',')); + if (q) qs.set('q', q); + const ds = await api(`/catalog?${qs}`); + listEl.replaceChildren(...(ds.length ? ds.map((d) => h('div', { class: 'dest-row' }, h('span'), + h('img', { class: 'fav', src: `/d/${d.slug}/icon`, alt: '', loading: 'lazy' }), + h('div', { class: 'd' }, h('b', {}, h('a', { href: d.website_url || `https://${d.canonical_domain}`, target: '_blank', rel: 'noopener noreferrer' }, d.name)), h('span', {}, d.description || d.canonical_domain)), + h('div', { class: 'nums' }, h('span', { class: 'pill' }, channelName(d.channel)), pricePill(d.fee_status), d.domain_rating != null ? h('span', { class: 'pill' }, `DR ${d.domain_rating}`) : null))) : [h('div', { class: 'empty muted' }, 'Nothing matches.')])); + }; + const search = h('input', { type: 'search', placeholder: 'Search by name, topic or category' }); + let t; + search.addEventListener('input', () => { + clearTimeout(t); + t = setTimeout(() => ((q = search.value.trim()), load()), 250); + }); + await load(); + return h('div', {}, pageHead('Destinations', `${stats.total} launch sites and directories · ${stats.free} free to list · ${stats.automated} submit automatically`), + h('div', { class: 'filters' }, CHANNELS.map(([k, label]) => { + const cb = h('input', { type: 'checkbox' }); + cb.addEventListener('change', () => (cb.checked ? chosen.add(k) : chosen.delete(k), load())); + return h('label', { class: 'chip' }, cb, `${label} ${stats.by_channel[k] ?? 0}`); + })), h('div', { class: 'filters' }, search), listEl); +} + +// ── Settings ── +async function settingsPage() { + me = await api('/api/me'); + const keys = await api('/keys'); + const root = h('div', {}, pageHead('Settings', me.user?.email)); + + // Passkeys + const pkList = h('div', { class: 'list' }, me.passkeys.length ? me.passkeys.map((k) => h('div', { class: 'item' }, h('span', {}, '🔑'), + h('div', {}, h('div', { class: 'title' }, k.name || 'Passkey'), h('div', { class: 'sub' }, h('span', {}, `added ${when(k.created_at)}`), k.last_used_at ? h('span', {}, `· last used ${when(k.last_used_at)}`) : null)), + h('button', { class: 'btn sm danger', type: 'button', onclick: async () => { + if (!confirm('Remove this passkey?')) return; + await api(`/api/account/passkeys/${encodeURIComponent(k.id)}`, { method: 'DELETE' }); + render(); + } }, 'Remove'))) : [h('div', { class: 'item' }, h('span'), h('div', { class: 'muted' }, 'No passkeys yet.'), h('span'))]); + const addPk = h('button', { class: 'btn primary', type: 'button', disabled: !passkeysSupported() }, 'Add a passkey'); + addPk.addEventListener('click', async () => { + try { + const { challenge_id, options } = await post('/api/account/passkeys/options'); + const response = await createPasskey(options); + const name = /Mac|iPhone|iPad/.test(navigator.userAgent) ? 'Apple device' : /Android/.test(navigator.userAgent) ? 'Android device' : /Windows/.test(navigator.userAgent) ? 'Windows device' : 'Passkey'; + await post('/api/account/passkeys', { challenge_id, response, name }); + toast('Passkey added'); + render(); + } catch (e) { + if (e.name !== 'NotAllowedError') toast(e.message); + } + }); + root.append(h('div', { class: 'section-title' }, 'Passkeys'), h('p', { class: 'muted' }, 'Sign in with your fingerprint, face or device PIN. No password to remember.'), pkList, h('div', { class: 'btn-row' }, addPk)); + + // Password + const pw = h('input', { type: 'password', autocomplete: 'new-password', minlength: 10, placeholder: 'At least 10 characters' }); + root.append(h('div', { class: 'section-title' }, me.has_password ? 'Change password' : 'Add a password'), + h('div', { class: 'card' }, h('p', { class: 'muted small' }, 'Optional. Useful on devices without passkeys. You can always sign in with an emailed link.'), + h('div', { class: 'btn-row' }, pw, h('button', { class: 'btn', type: 'button', onclick: async () => { + try { + await post('/api/account/password', { password: pw.value }); + pw.value = ''; + toast('Password saved'); + } catch (e) { + toast(e.message); + } + } }, 'Save')))); + + // API keys + const label = h('input', { type: 'text', placeholder: 'What is it for? e.g. Claude Code', maxlength: 80 }); + const role = h('select', {}, h('option', { value: 'agent' }, 'Agent: can plan and ask'), h('option', { value: 'owner' }, 'Owner: full access')); + const shown = h('div'); + root.append(h('div', { class: 'section-title' }, 'API keys'), + h('p', { class: 'muted' }, 'For agents, scripts and the MCP server. Agent keys can plan campaigns and request approval; only you approve.'), + shown, + h('div', { class: 'list' }, keys.length ? keys.map((k) => h('div', { class: 'item' }, h('span', { class: 'pill' }, k.role), + h('div', {}, h('div', { class: 'title' }, k.label), h('div', { class: 'sub' }, h('span', {}, `created ${when(k.created_at)}`), h('span', {}, k.last_used_at ? `· used ${when(k.last_used_at)}` : '· never used'))), + h('button', { class: 'btn sm danger', type: 'button', onclick: async () => { + if (!confirm('Revoke this key? Anything using it stops working.')) return; + await api(`/keys/${k.id}`, { method: 'DELETE' }); + render(); + } }, 'Revoke'))) : [h('div', { class: 'item' }, h('span'), h('div', { class: 'muted' }, 'No keys.'), h('span'))]), + h('div', { class: 'btn-row' }, label, role, h('button', { class: 'btn', type: 'button', onclick: async () => { + const k = await post('/keys', { role: role.value, label: label.value.trim() || 'API key' }); + shown.replaceChildren(h('div', { class: 'notice ok' }, h('b', {}, 'Copy this key now. It is not shown again.'), h('div', { class: 'token' }, k.token), + h('div', { class: 'btn-row' }, h('button', { class: 'btn sm', type: 'button', onclick: () => copy(k.token, 'Key copied') }, 'Copy key')))); + label.value = ''; + } }, 'Create key'))); + + // CLI + MCP + const origin = location.origin; + root.append(h('div', { class: 'section-title' }, 'CLI and MCP'), + h('div', { class: 'card stack' }, + h('p', {}, 'Install the CLI and sign in from your terminal:'), + h('pre', {}, h('code', {}, `curl -fsSL ${origin}/install.sh | sh\nsubmitfleet login`)), + h('p', {}, 'Connect an MCP client (Claude Code, Cursor, …) with an agent key:'), + h('pre', {}, h('code', {}, `claude mcp add --transport http submitfleet ${origin}/mcp --header "Authorization: Bearer "`)))); + + root.append(h('div', { class: 'section-title' }, 'Session'), h('button', { class: 'btn', type: 'button', onclick: signOut }, 'Sign out')); + return root; +} + +async function signOut() { + await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: '{}' }); + location.assign('/'); +} + +$('#pause-all').addEventListener('click', async () => { + if (!confirm('Stop every automated submission now? Assisted work is not affected.')) return; + await post('/pause'); + toast('Automated submissions paused'); +}); + +api('/api/me').then((m) => { + me = m; + $('#who').textContent = m.user?.email || ''; +}).catch(() => {}); +render(); diff --git a/web/assets/auth.js b/web/assets/auth.js new file mode 100644 index 0000000..65e1782 --- /dev/null +++ b/web/assets/auth.js @@ -0,0 +1,116 @@ +import { getPasskey, passkeysSupported } from './webauthn.js'; + +const $ = (s) => document.querySelector(s); +const params = new URLSearchParams(location.search); +const next = (() => { + const n = params.get('next'); + return n && /^\/(?!\/)/.test(n) ? n : '/app'; +})(); + +function say(text, kind = 'info') { + const m = $('#msg'); + m.className = `notice ${kind}`; + m.textContent = text; +} + +async function post(path, body) { + const res = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}), credentials: 'same-origin' }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.message || data.error || `Something went wrong (HTTP ${res.status})`); + return data; +} + +async function signInWithPasskey(mediation) { + const { challenge_id, options } = await post('/api/auth/passkey/login/options'); + const response = await getPasskey(options, mediation); + await post('/api/auth/passkey/login/verify', { challenge_id, response }); + location.assign(next); +} + +// ── /auth/link: spend the emailed token only when the person presses Continue ── +if (location.pathname === '/auth/link') { + const token = new URLSearchParams(location.hash.slice(1)).get('t') || params.get('t'); + history.replaceState(null, '', '/auth/link'); + const btn = $('#continue'); + if (!token) { + say('This link is incomplete. Request a new one from the sign-in page.', 'bad'); + btn.disabled = true; + } + btn.addEventListener('click', async () => { + btn.disabled = true; + try { + const r = await post('/api/auth/link', { token }); + location.assign(r.created ? '/app?welcome=1' : next); + } catch (e) { + say(e.message, 'bad'); + btn.disabled = false; + } + }); +} else { + const signup = location.pathname === '/signup'; + let mode = 'password'; + if (signup) { + $('#title').textContent = 'Create your account'; + $('#subtitle').textContent = 'Free to start. We email you once to confirm the address.'; + $('#name-field').hidden = false; + $('#password').autocomplete = 'new-password'; + $('#password-hint').hidden = false; + $('#submit').textContent = 'Create account'; + $('#passkey').hidden = true; + document.querySelector('.divider').hidden = true; + $('#tab-password').textContent = 'With a password'; + $('#tab-link').textContent = 'Email link only'; + $('#switch').innerHTML = 'Already have an account? Sign in'; + } else { + $('#switch').innerHTML = 'New here? Create an account'; + } + if (params.get('next')) for (const a of document.querySelectorAll('#switch a')) a.href += `?next=${encodeURIComponent(next)}`; + + const setMode = (m) => { + mode = m; + $('#tab-password').setAttribute('aria-selected', String(m === 'password')); + $('#tab-link').setAttribute('aria-selected', String(m === 'link')); + $('#password-field').hidden = m !== 'password'; + $('#submit').textContent = m === 'link' ? (signup ? 'Email me a sign-up link' : 'Email me a sign-in link') : signup ? 'Create account' : 'Sign in'; + }; + $('#tab-password').addEventListener('click', () => setMode('password')); + $('#tab-link').addEventListener('click', () => setMode('link')); + + if (!passkeysSupported()) { + $('#passkey').hidden = true; + document.querySelector('.divider').hidden = true; + } + $('#passkey').addEventListener('click', async () => { + try { + await signInWithPasskey(); + } catch (e) { + if (e.name !== 'NotAllowedError') say(e.message, 'bad'); + } + }); + + $('#form').addEventListener('submit', async (ev) => { + ev.preventDefault(); + const email = $('#email').value.trim(); + const password = $('#password').value; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return say('Enter a valid email address.', 'bad'); + const btn = $('#submit'); + btn.disabled = true; + try { + if (mode === 'link') { + const r = await post(signup ? '/api/auth/signup' : '/api/auth/magic', { email, name: $('#name').value.trim() || undefined }); + say(r.message, 'ok'); + } else if (signup) { + if (password.length < 10) throw new Error('Use a password of at least 10 characters, or choose "Email link only".'); + const r = await post('/api/auth/signup', { email, password, name: $('#name').value.trim() || undefined }); + say(`${r.message} Open it to confirm your account.`, 'ok'); + } else { + await post('/api/auth/login', { email, password }); + location.assign(next); + return; + } + } catch (e) { + say(e.message, 'bad'); + } + btn.disabled = false; + }); +} diff --git a/web/assets/dest.svg b/web/assets/dest.svg new file mode 100644 index 0000000..88fb920 --- /dev/null +++ b/web/assets/dest.svg @@ -0,0 +1 @@ + diff --git a/web/assets/icon.svg b/web/assets/icon.svg new file mode 100644 index 0000000..49bdeda --- /dev/null +++ b/web/assets/icon.svg @@ -0,0 +1 @@ + diff --git a/web/assets/site.css b/web/assets/site.css new file mode 100644 index 0000000..eb1004c --- /dev/null +++ b/web/assets/site.css @@ -0,0 +1,183 @@ +/* SubmitFleet design tokens + shared components (landing, auth, app). */ +:root { + --bg: #f7f7f4; + --bg-2: #efefea; + --card: #ffffff; + --fg: #15171a; + --fg-2: #3c4047; + --muted: #6b7078; + --line: #e3e3dc; + --line-2: #d4d4cc; + --accent: #0b6e4f; + --accent-2: #0e8a63; + --accent-soft: #e3f3ec; + --accent-fg: #ffffff; + --warn: #9a5b00; + --warn-soft: #fbf0dc; + --bad: #b42318; + --bad-soft: #fde8e6; + --info: #2457c5; + --info-soft: #e7eefc; + --radius: 12px; + --radius-sm: 8px; + --shadow: 0 1px 2px rgba(20, 22, 26, 0.04), 0 4px 16px rgba(20, 22, 26, 0.06); + --font: ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #101113; + --bg-2: #17191c; + --card: #191b1f; + --fg: #eceee9; + --fg-2: #c5c8c4; + --muted: #9599a0; + --line: #2a2d32; + --line-2: #363a40; + --accent: #3fbf8f; + --accent-2: #5ad1a4; + --accent-soft: #15322a; + --accent-fg: #07130e; + --warn: #e7ae5a; + --warn-soft: #33270f; + --bad: #f08a80; + --bad-soft: #3a1714; + --info: #8ab0ff; + --info-soft: #172443; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.25); + color-scheme: dark; + } +} + +*, *::before, *::after { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.55 var(--font); } +a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; } +img { max-width: 100%; } +h1, h2, h3 { line-height: 1.2; letter-spacing: -0.015em; margin: 0 0 0.5em; } +p { margin: 0 0 1em; } +code, kbd, pre { font-family: var(--mono); font-size: 0.9em; } +code { background: var(--bg-2); border: 1px solid var(--line); border-radius: 5px; padding: 0.05em 0.35em; } +pre { background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 12px 14px; overflow-x: auto; } +pre code { background: none; border: 0; padding: 0; } +.wrap { max-width: 1080px; margin: 0 auto; padding: 0 20px; } +.muted { color: var(--muted); } +.small { font-size: 13px; } +.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +/* Buttons */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + min-height: 40px; padding: 0 16px; border-radius: var(--radius-sm); + border: 1px solid var(--line-2); background: var(--card); color: var(--fg); + font: 600 14px/1 var(--font); text-decoration: none; cursor: pointer; white-space: nowrap; + transition: background 0.12s, border-color 0.12s, transform 0.05s; +} +.btn:hover { border-color: var(--muted); } +.btn:active { transform: translateY(1px); } +.btn:disabled { opacity: 0.55; cursor: default; transform: none; } +.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); } +.btn.primary:hover { background: var(--accent-2); border-color: var(--accent-2); } +.btn.ghost { background: transparent; border-color: transparent; } +.btn.ghost:hover { background: var(--bg-2); } +.btn.danger { color: var(--bad); } +.btn.lg { min-height: 48px; padding: 0 22px; font-size: 15px; } +.btn.sm { min-height: 32px; padding: 0 10px; font-size: 13px; } +.btn.block { width: 100%; } + +/* Forms */ +label { display: block; font-weight: 600; font-size: 13px; margin: 0 0 6px; color: var(--fg-2); } +.field { margin: 0 0 16px; } +.hint { font-size: 12.5px; color: var(--muted); margin-top: 5px; } +input[type=text], input[type=email], input[type=password], input[type=url], input[type=search], textarea, select { + width: 100%; min-height: 42px; padding: 9px 12px; border-radius: var(--radius-sm); + border: 1px solid var(--line-2); background: var(--card); color: var(--fg); font: 15px/1.4 var(--font); +} +textarea { min-height: 110px; resize: vertical; } +input:focus, textarea:focus, select:focus, .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); } +.check { display: flex; gap: 8px; align-items: center; font-weight: 500; font-size: 14px; color: var(--fg); margin: 0; cursor: pointer; } +.check input { width: 17px; height: 17px; accent-color: var(--accent); } + +/* Surfaces */ +.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow); } +.notice { border-radius: var(--radius-sm); padding: 10px 14px; font-size: 14px; margin: 0 0 16px; border: 1px solid transparent; } +.notice.ok { background: var(--accent-soft); color: var(--fg); border-color: color-mix(in srgb, var(--accent) 30%, transparent); } +.notice.warn { background: var(--warn-soft); color: var(--fg); border-color: color-mix(in srgb, var(--warn) 30%, transparent); } +.notice.bad { background: var(--bad-soft); color: var(--fg); border-color: color-mix(in srgb, var(--bad) 30%, transparent); } +.notice.info { background: var(--info-soft); color: var(--fg); border-color: color-mix(in srgb, var(--info) 30%, transparent); } +.pill { display: inline-flex; align-items: center; gap: 5px; font: 600 11.5px/1 var(--font); padding: 4px 8px; border-radius: 99px; background: var(--bg-2); color: var(--fg-2); border: 1px solid var(--line); white-space: nowrap; } +.pill.green { background: var(--accent-soft); color: var(--accent); border-color: transparent; } +.pill.amber { background: var(--warn-soft); color: var(--warn); border-color: transparent; } +.pill.red { background: var(--bad-soft); color: var(--bad); border-color: transparent; } +.pill.blue { background: var(--info-soft); color: var(--info); border-color: transparent; } + +.brand { display: inline-flex; align-items: center; gap: 10px; font-weight: 750; font-size: 17px; color: var(--fg); text-decoration: none; letter-spacing: -0.01em; } +.brand img { width: 28px; height: 28px; } + +/* ── Landing ── */ +.site-nav { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); } +.site-nav .wrap { display: flex; align-items: center; gap: 12px; height: 62px; } +.site-nav nav { margin-left: auto; display: flex; gap: 6px; align-items: center; } +.site-nav nav a.link { color: var(--fg-2); text-decoration: none; font-weight: 550; font-size: 14px; padding: 8px 10px; border-radius: 8px; } +.site-nav nav a.link:hover { background: var(--bg-2); } +@media (max-width: 640px) { .site-nav nav a.link.hide-sm { display: none; } } + +.hero { padding: 72px 0 40px; } +.hero .eyebrow { display: inline-flex; gap: 8px; align-items: center; font-weight: 600; font-size: 13px; color: var(--accent); background: var(--accent-soft); padding: 6px 12px; border-radius: 99px; margin-bottom: 20px; } +.hero h1 { font-size: clamp(34px, 6vw, 58px); max-width: 19ch; letter-spacing: -0.03em; } +.hero p.lead { font-size: clamp(17px, 2.2vw, 20px); color: var(--fg-2); max-width: 46ch; margin-bottom: 28px; } +.hero .cta { display: flex; gap: 10px; flex-wrap: wrap; } +.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; margin: 48px 0 0; } +.stats div { background: var(--card); padding: 18px 20px; } +.stats b { display: block; font-size: 28px; letter-spacing: -0.02em; } +.stats span { color: var(--muted); font-size: 13px; } +@media (max-width: 720px) { .stats { grid-template-columns: repeat(2, 1fr); } } + +section.block { padding: 64px 0; } +section.block h2 { font-size: clamp(26px, 3.6vw, 36px); max-width: 22ch; } +section.block .sub { color: var(--fg-2); max-width: 60ch; font-size: 17px; margin-bottom: 32px; } +.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; counter-reset: step; } +.steps .card { box-shadow: none; } +.steps .card::before { counter-increment: step; content: counter(step); display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: var(--accent-soft); color: var(--accent); font-weight: 700; margin-bottom: 12px; } +.steps h3 { font-size: 17px; } +.steps p { color: var(--fg-2); margin: 0; } +.features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } +.features .card { box-shadow: none; } +.features h3 { font-size: 16px; } +.features p { color: var(--fg-2); margin: 0; font-size: 14.5px; } +@media (max-width: 860px) { .steps, .features { grid-template-columns: 1fr; } } + +.demo { display: grid; grid-template-columns: 1.1fr 1fr; gap: 28px; align-items: center; } +@media (max-width: 860px) { .demo { grid-template-columns: 1fr; } } +.mock { background: var(--card); border: 1px solid var(--line); border-radius: 16px; box-shadow: var(--shadow); overflow: hidden; } +.mock .bar { display: flex; gap: 6px; padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--bg-2); } +.mock .bar i { width: 10px; height: 10px; border-radius: 50%; background: var(--line-2); } +.mock .row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); font-size: 14px; } +.mock .row:last-child { border-bottom: 0; } +.mock .row small { color: var(--muted); display: block; } + +.channels { display: flex; flex-wrap: wrap; gap: 8px; } +.channels .pill { font-size: 13px; padding: 7px 12px; } +.cta-band { background: var(--fg); color: var(--bg); border-radius: 18px; padding: 44px 32px; display: flex; flex-wrap: wrap; gap: 20px; align-items: center; justify-content: space-between; } +.cta-band h2 { margin: 0; color: var(--bg); } +.cta-band p { margin: 6px 0 0; color: color-mix(in srgb, var(--bg) 75%, transparent); } +.cta-band .btn.primary { background: var(--bg); color: var(--fg); border-color: var(--bg); } +footer.site { border-top: 1px solid var(--line); padding: 28px 0 40px; color: var(--muted); font-size: 13.5px; } +footer.site .wrap { display: flex; flex-wrap: wrap; gap: 16px; justify-content: space-between; } +footer.site a { color: var(--fg-2); text-decoration: none; margin-right: 14px; } + +/* ── Auth pages ── */ +.auth-page { min-height: 100vh; display: grid; place-items: center; padding: 32px 16px; } +.auth-box { width: 100%; max-width: 420px; } +.auth-box .brand { margin-bottom: 24px; } +.auth-box h1 { font-size: 26px; } +.auth-box .card { padding: 26px; } +.divider { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: 12.5px; margin: 18px 0; } +.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--line); } +.tabs { display: flex; background: var(--bg-2); border-radius: 10px; padding: 4px; margin-bottom: 18px; } +.tabs button { flex: 1; border: 0; background: none; padding: 8px; border-radius: 7px; font: 600 13.5px/1 var(--font); color: var(--muted); cursor: pointer; } +.tabs button[aria-selected=true] { background: var(--card); color: var(--fg); box-shadow: var(--shadow); } +.btn-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } +#switch { margin-top: 16px; } +[hidden] { display: none !important; } diff --git a/web/assets/webauthn.js b/web/assets/webauthn.js new file mode 100644 index 0000000..5664f33 --- /dev/null +++ b/web/assets/webauthn.js @@ -0,0 +1,61 @@ +// Minimal WebAuthn JSON helpers (the shapes @simplewebauthn/server expects), so no CDN is needed. +const b64u = { + toBuf(s) { + const pad = '='.repeat((4 - (s.length % 4)) % 4); + const bin = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/')); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)).buffer; + }, + fromBuf(buf) { + let s = ''; + for (const b of new Uint8Array(buf)) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + }, +}; + +export const passkeysSupported = () => typeof window.PublicKeyCredential === 'function' && !!navigator.credentials; + +export async function createPasskey(options) { + const publicKey = { + ...options, + challenge: b64u.toBuf(options.challenge), + user: { ...options.user, id: b64u.toBuf(options.user.id) }, + excludeCredentials: (options.excludeCredentials || []).map((c) => ({ ...c, id: b64u.toBuf(c.id) })), + }; + const cred = await navigator.credentials.create({ publicKey }); + const r = cred.response; + return { + id: cred.id, + rawId: b64u.fromBuf(cred.rawId), + type: cred.type, + authenticatorAttachment: cred.authenticatorAttachment || undefined, + clientExtensionResults: cred.getClientExtensionResults(), + response: { + clientDataJSON: b64u.fromBuf(r.clientDataJSON), + attestationObject: b64u.fromBuf(r.attestationObject), + transports: typeof r.getTransports === 'function' ? r.getTransports() : [], + }, + }; +} + +export async function getPasskey(options, mediation) { + const publicKey = { + ...options, + challenge: b64u.toBuf(options.challenge), + allowCredentials: (options.allowCredentials || []).map((c) => ({ ...c, id: b64u.toBuf(c.id) })), + }; + const cred = await navigator.credentials.get({ publicKey, ...(mediation ? { mediation } : {}) }); + const r = cred.response; + return { + id: cred.id, + rawId: b64u.fromBuf(cred.rawId), + type: cred.type, + authenticatorAttachment: cred.authenticatorAttachment || undefined, + clientExtensionResults: cred.getClientExtensionResults(), + response: { + clientDataJSON: b64u.fromBuf(r.clientDataJSON), + authenticatorData: b64u.fromBuf(r.authenticatorData), + signature: b64u.fromBuf(r.signature), + userHandle: r.userHandle ? b64u.fromBuf(r.userHandle) : undefined, + }, + }; +} diff --git a/web/auth.html b/web/auth.html new file mode 100644 index 0000000..6015b60 --- /dev/null +++ b/web/auth.html @@ -0,0 +1,46 @@ + + + + + +SubmitFleet: sign in + + + + +
+
+ SubmitFleet +

Sign in

+

Welcome back.

+
+
+ +
or
+
+ + +
+
+ +
+ + +
+
+ + + +
+ +
+
+

+
+
+ + + diff --git a/web/authorize.html b/web/authorize.html new file mode 100644 index 0000000..137872e --- /dev/null +++ b/web/authorize.html @@ -0,0 +1,29 @@ + + + + + +SubmitFleet: authorize + + + + +
+
+ SubmitFleet +

Allow {{CLIENT}}?

+
+

{{CLIENT}} on your computer wants to use your SubmitFleet account ({{EMAIL}}): your products, campaigns and reports.

+

It can plan campaigns and ask for approval. Only allow this if you just ran submitfleet login.

+
+ {{HIDDEN}} +
+ + +
+
+
+
+
+ + diff --git a/web/docs.html b/web/docs.html new file mode 100644 index 0000000..762a905 --- /dev/null +++ b/web/docs.html @@ -0,0 +1,67 @@ + + + + + +SubmitFleet docs + + + + + + +
+
+

Docs

+

SubmitFleet has four ways in: the web app, a CLI, an MCP server for agents, and an HTTP API. They all share the same account, products and campaigns.

+ +

How a submission works

+

Every destination is submitted one of two ways:

+
    +
  • Assisted. SubmitFleet prepares every field from your approved product details, links you to the submit page and tracks the result. You paste and press submit, then mark it submitted and, once it appears, live with its URL. This works for every destination in the catalog.
  • +
  • Automatic. Where a destination has an API that permits it and we have tested an adapter against it, workers submit for you after you approve the campaign, and record the receipt. Only destinations that pass every check are automatic; the destinations page shows how many that is today.
  • +
+

Either way, nothing is submitted until you approve the exact list, listing fees are always $0 (paid routes are excluded), and a product is never submitted twice to the same place.

+ +

CLI

+
curl -fsSL https://submitfleet.com/install.sh | sh
+submitfleet login                    # opens your browser, signs the CLI in (OAuth 2.1 + PKCE)
+submitfleet whoami
+submitfleet products list
+submitfleet catalog search ai --json
+submitfleet campaign status CAMPAIGN_ID --json
+submitfleet report export CAMPAIGN_ID --format markdown
+submitfleet mcp --stdio              # MCP over stdio, signed in as you
+

Without login, the CLI runs fully local against its own database (self-hosted mode). submitfleet help lists every command.

+ +

MCP

+

Create an agent key in Settings, then add the server to your client:

+
claude mcp add --transport http submitfleet https://submitfleet.com/mcp \
+  --header "Authorization: Bearer sf_agt_..."
+

Tools are prefixed submitfleet_: products, catalog search, campaign plan, preview, status, reports, the human-action queue and more. An agent can plan a campaign and request approval. There is no tool that grants approval: you approve in the app.

+ +

HTTP API

+

Base URL https://submitfleet.com/api/v1, bearer API key in the Authorization header. JSON in and out; errors carry an error code and a message.

+
GET  /products                      POST /products
+POST /products/extract   {url}      PUT  /products/:id/logo  (image body)
+POST /products/:id/approve-facts    (owner)
+GET  /catalog?q=&channel=&pricing=  GET  /catalog-stats
+POST /campaigns  {product_id, destinations?, channels?, mode?}
+POST /campaigns/:id/approval-request
+POST /campaigns/:id/approve  {scope_hash}   (owner)
+POST /campaigns/:id/run      GET  /campaigns/:id/queue
+GET  /submissions/:id/kit    POST /submissions/:id/mark {action, listing_url?, note?}
+GET  /reports/:id?format=markdown|json|csv
+ +

Self-hosting

+

SubmitFleet is MIT-licensed. npm i -g @profullstack/submitfleet, then submitfleet serve runs the app, API, MCP and workers in one process on SQLite (or Turso). Source and issues: github.com/profullstack/submitfleet.

+
+
+ + + diff --git a/web/error.html b/web/error.html new file mode 100644 index 0000000..0e9cb05 --- /dev/null +++ b/web/error.html @@ -0,0 +1,17 @@ + + + + + +SubmitFleet: error + + + +
+
+ SubmitFleet +

That did not work

{{MESSAGE}}

Home
+
+
+ + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..be6bff3 --- /dev/null +++ b/web/index.html @@ -0,0 +1,107 @@ + + + + + +SubmitFleet: launch everywhere from one product profile + + + + + + + + + + +
+
+ {{TOTAL}} launch sites and directories, mapped +

Launch everywhere from one product profile.

+

Describe your product once. SubmitFleet matches it to the launch sites and directories worth your time, prepares every form field for each one, and tracks every submission until the listing is live.

+ +
+
{{TOTAL}}destinations in the catalog
+
{{FREE}}free to list
+
{{DIRECTORIES}}directories
+
{{LAUNCH}}launch sites
+
+
+ +
+

From one URL to a tracked launch

+

The slow part of a launch is not deciding to do it. It is filling in the same form a hundred times and losing track of where you already are.

+
+

Add your product

Paste your URL. SubmitFleet reads your page and drafts the name, tagline, description and logo. You fix anything that is off and approve it once.

+

Pick where to launch

Filter the catalog by type, price and audience. Free listings only by default. Anywhere you already submitted is skipped automatically, across every campaign.

+

Work the queue

Each destination comes with its submit link and every field ready to copy, cut to the length forms expect. Mark it submitted, then live, and keep the listing URL as proof.

+
+
+ +
+
+
+

A launch queue, not a spreadsheet

+

See what is waiting for you, what is under review and what is live, for every product you launch. Reports export as Markdown, JSON or CSV.

+

Where a destination offers an API that allows it, SubmitFleet submits for you, with an approval step first and a receipt after. Everywhere else it does all the preparation and you press the button: usually a minute or two per site.

+
+ +
+
+ +
+

Built for people who launch more than once

+

Founders with several products, studios and agencies: one workspace, every product, every destination.

+
+

One approved profile

Every submission uses facts you approved. Nothing is invented, and changing a fact asks for approval again.

+

No double submissions

Duplicates are blocked per product and destination, even across campaigns, aliases and reworded pitches.

+

Proof of what went live

Submitted, in review and live are separate states. Live means a listing URL, not a hopeful guess.

+

Honest catalog

Each destination shows its price, audience, eligibility, link type and traffic, so you choose for fit, not for a number.

+

Agents welcome

A CLI, an HTTP API and an MCP server. An agent can plan a campaign and ask you to approve it. It cannot approve it for you.

+

Open source

Run it yourself with one command, or use it here. Your data exports in full at any time.

+
+
+ +
+

What is in the catalog

+

Curated from the open awesome-submitlist catalog and kept current. Free directories and launch sites are selected by default; the rest are one click away.

+
+ DirectoriesProduct launch sitesBusiness citationsCommunitiesNewslettersSubredditsMarketplaces +
+
+ +
+
+

Launch your next product properly.

Sign up with a passkey, a password or an emailed link.

+ Get started +
+
+
+ + + + diff --git a/web/link.html b/web/link.html new file mode 100644 index 0000000..9b3382f --- /dev/null +++ b/web/link.html @@ -0,0 +1,24 @@ + + + + + +SubmitFleet: confirm sign-in + + + + +
+
+ SubmitFleet +

Continue to SubmitFleet

+
+
+

Press continue to finish signing in on this device.

+ +
+
+
+ + +