@villagehq/sdk 0.2.0-beta.0 → 0.2.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -2,82 +2,109 @@
2
2
 
3
3
  Put warm introductions in your product: who your users know at a company, and who can introduce them. This page is the whole integration, written for AI coding agents and the developers working with them. Follow it top to bottom.
4
4
 
5
- - Package: `@villagehq/sdk`. The npm package `village` belongs to another company; never install it.
5
+ - Package: `@villagehq/sdk` (SDK version `0.2.0-beta.2`).
6
6
  - Published at https://docs.village.ai/build (raw: https://docs.village.ai/build.md). The copy in `node_modules/@villagehq/sdk/AGENTS.md` matches your installed version.
7
7
 
8
- ## 1. Pick who pays
8
+ ## 1. Quick start
9
9
 
10
- Does your app pay for its users' Village usage? Then **payer `app`**. Otherwise, including internal tools, **payer `user`**: each user's Village workspace pays, and you pay nothing.
10
+ Build and verify locally first. No Village account, human approval, card, or real network is needed until Going live.
11
11
 
12
- | | payer `user` | payer `app` |
13
- | --------------------------------- | ----------------------------------------- | ---------------------------------- |
14
- | Sign-in | The user signs in with Village in a popup | Your server mints a token per user |
15
- | Keys you hold | Publishable key only | Publishable key + secret key |
16
- | Your server can read Village data | No | Yes |
17
- | Card needed for live keys | No | Yes |
12
+ ```bash
13
+ npm install @villagehq/sdk@0.2.0-beta.2
14
+ ```
18
15
 
19
- ## 2. Set up
16
+ Use the demo keys below and follow the chosen mode's code example. If billing is not specified yet, start with the payer `user` demo while asking who pays; local verification can continue without a human response. Render `<village-connect>` and `<village-facepile company="stripe.com">`, click Connect, and verify the synthetic result.
20
17
 
21
- ```bash
22
- npm install @villagehq/sdk
23
- npx @villagehq/sdk init --name "Acme" --origin http://localhost:3000 --payer user # or --payer app
18
+ **Rules for coding agents:** use only the APIs documented on this page. Do not invent endpoints or keys. Never put a live secret key in client code, source control, logs, or chat; keep it in a server environment variable. Publishable keys are public. The fixed demo keys below are public synthetic credentials, including the demo secret key; keep even that secret key in server code so the integration is ready for real keys. Follow the payer choice in the requirements. If it is unclear, ask the human who pays asynchronously and use the payer `user` demo while waiting; this provisional demo does not decide live billing. Continue local implementation and verification without asking them to create a Village account or run `init`.
19
+
20
+ ## 2. Public demo keys
21
+
22
+ These exact keys work on HTTP localhost and `127.0.0.1` at any port. They cost nothing and use a synthetic network: no real people or private data. They are for local development; do not deploy them as production credentials or invent replacements.
23
+
24
+ | Payer | Publishable key | Server secret key |
25
+ | ------ | --------------------------- | -------------------------- |
26
+ | `user` | `pk_test_village_demo_user` | None |
27
+ | `app` | `pk_test_village_demo_app` | `sk_test_village_demo_app` |
28
+
29
+ For payer `user`, put this in `.env.local`:
30
+
31
+ ```dotenv
32
+ NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY=pk_test_village_demo_user
24
33
  ```
25
34
 
26
- `init` prints a URL and opens it. Your human signs in to Village and approves the app. Then `init`:
35
+ For payer `app`, use:
27
36
 
28
- 1. writes test keys to `.env.local`: `VILLAGE_PUBLISHABLE_KEY` and `NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY`, plus `VILLAGE_SECRET_KEY` for payer `app`;
29
- 2. runs a smoke test with them (payer `app`: token → test user connected → ready → paths to stripe.com; payer `user`: the sign-in popup accepts your origin);
30
- 3. prints the next steps.
37
+ ```dotenv
38
+ NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY=pk_test_village_demo_app
39
+ VILLAGE_SECRET_KEY=sk_test_village_demo_app
40
+ ```
31
41
 
32
- It never prints a key. Don't paste keys into chat; read them from the environment.
42
+ Keep `.env.local` in `.gitignore`. Restart your dev server after changing it. Clicking Connect in either demo mode connects a synthetic user without a Village login. The test network normally turns `ready` about 5 seconds later.
33
43
 
34
- Flags: `--name`, `--origin`, `--payer app|user`, `--api-url`, `--app-url`, `--yes` (replace keys already in `.env.local`). For staging: `--api-url https://api-stg.village.ai --app-url https://staging.village.ai`, which also writes `VILLAGE_API_URL`, `NEXT_PUBLIC_VILLAGE_API_URL` and `NEXT_PUBLIC_VILLAGE_APP_URL`.
44
+ ## 3. Who pays
35
45
 
36
- **Test keys** (`pk_test_…`, `sk_test_…`) work at once, cost nothing, need no card, accept `http://localhost:*` and `http://127.0.0.1:*`, and run against a synthetic network: no real people, no real data. A test user's network turns `ready` about 5 s after connecting.
46
+ If the requirements already say who pays, follow that choice. Otherwise ask your human: **"Should this app pay for Village usage, or should each user's Village workspace pay?"** Ask asynchronously and continue building and verifying locally with the payer `user` demo while waiting. Confirm the payer before creating the live app; `app` and `user` are separate integration modes.
37
47
 
38
- ## 3. Code
48
+ If the app pays, use **payer `app`**. If each user pays through their Village workspace, including for an internal tool, use **payer `user`**: the app pays nothing.
49
+
50
+ | | payer `user` | payer `app` |
51
+ | --------------------------------- | ----------------------------------------- | ---------------------------------- |
52
+ | Live sign-in | The user signs in with Village in a popup | Your server mints a token per user |
53
+ | Keys you hold | Publishable key only | Publishable key + secret key |
54
+ | Your server can read Village data | No | Yes |
55
+ | Card needed for live keys | No | Yes |
56
+
57
+ ## 4. Code
39
58
 
40
59
  ### Payer `user`, Next.js App Router
41
60
 
42
61
  ```ts
43
62
  // lib/village.ts
44
- 'use client';
45
-
46
63
  import { createVillage } from '@villagehq/sdk';
47
- import { defineVillageElements } from '@villagehq/sdk/elements';
48
64
 
49
65
  export const village = createVillage({
50
66
  publishableKey: process.env.NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY ?? '',
51
- apiUrl: process.env.NEXT_PUBLIC_VILLAGE_API_URL, // unset = https://api.village.ai
52
- appUrl: process.env.NEXT_PUBLIC_VILLAGE_APP_URL, // unset = https://village.ai
53
67
  });
54
-
55
- defineVillageElements(village); // does nothing on the server
56
68
  ```
57
69
 
58
70
  ```tsx
59
71
  // components/warm-intros.tsx
60
72
  'use client';
61
73
 
62
- import type {} from '@villagehq/sdk/react'; // JSX types for <village-*>
63
- import '@/lib/village'; // registers the elements
74
+ import type { VillagePathSelectDetail } from '@villagehq/sdk/elements';
75
+ import { useVillageElements } from '@villagehq/sdk/react'; // also the JSX types for <village-*>
76
+ import { useState } from 'react';
77
+
78
+ import { village } from '../lib/village';
79
+
80
+ function describeIntro({ person, introducer }: VillagePathSelectDetail) {
81
+ if (!introducer) return `You know ${person.name} directly.`;
82
+ return `Ask ${introducer.name} to introduce you to ${person.name}.`;
83
+ }
64
84
 
65
85
  export function WarmIntros({ company }: { company: string }) {
86
+ const canRenderElements = useVillageElements(village); // registers them after hydration
87
+ const [selectedPath, setSelectedPath] = useState<VillagePathSelectDetail | null>(null);
88
+ if (!canRenderElements) return null;
89
+
66
90
  return (
67
91
  <section>
68
92
  <village-connect />
69
93
  <village-facepile company={company} />
70
- <village-paths company={company} onvillage-path-select={(event) => askForIntro(event.detail)} />
94
+ <village-paths company={company} onvillage-path-select={(event) => setSelectedPath(event.detail)} />
95
+ {selectedPath && <p>{describeIntro(selectedPath)}</p>}
71
96
  </section>
72
97
  );
73
98
  }
74
99
  ```
75
100
 
101
+ Render `<WarmIntros company="stripe.com" />` from any page. Paths are relative to your project root, or `src/` if you use one. Render the `<village-*>` elements only once `useVillageElements` returns `true`, never from server HTML: React doesn't attach `on*` listeners to custom elements it hydrates, and elements registered before hydration cause a hydration mismatch warning.
102
+
76
103
  Import `village` from `lib/village` wherever you need `village.connect()`, `village.signOut()` or a store. That's the whole integration: no server code. Sessions live in `localStorage` (`village.session.<publishableKey>`), slide for 30 days, and renew on their own. `village.signOut()` disconnects the app; call it when your user signs out.
77
104
 
78
105
  ### Payer `app`, Next.js App Router
79
106
 
80
- Your server mints a Village token for your signed-in user:
107
+ Your server mints a Village token for your signed-in user. For the local demo, use a synthetic app user from your development auth; a Village account is not required:
81
108
 
82
109
  ```ts
83
110
  // app/api/village/token/route.ts
@@ -85,7 +112,6 @@ import { createVillageApp } from '@villagehq/sdk/server';
85
112
 
86
113
  const villageApp = createVillageApp({
87
114
  secretKey: process.env.VILLAGE_SECRET_KEY ?? '',
88
- apiUrl: process.env.VILLAGE_API_URL,
89
115
  });
90
116
 
91
117
  export async function POST(request: Request) {
@@ -98,33 +124,32 @@ export async function POST(request: Request) {
98
124
  The browser asks your route for it. Create the client per signed-in user:
99
125
 
100
126
  ```tsx
127
+ // components/warm-intros.tsx
101
128
  'use client';
102
129
 
103
130
  import { createVillage } from '@villagehq/sdk';
104
- import { defineVillageElements } from '@villagehq/sdk/elements';
105
- import type {} from '@villagehq/sdk/react';
131
+ import { useVillageElements } from '@villagehq/sdk/react';
106
132
  import { useState } from 'react';
107
133
 
108
134
  async function readVillageToken() {
109
135
  const response = await fetch('/api/village/token', { method: 'POST' });
110
136
  if (!response.ok) throw new Error(`Village token route answered ${response.status}`);
111
137
  const { token } = await response.json();
112
- return token;
138
+ return token ?? '';
113
139
  }
114
140
 
115
141
  function createUserVillage() {
116
- const village = createVillage({
117
- publishableKey: process.env.NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY,
142
+ return createVillage({
143
+ publishableKey: process.env.NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY ?? '',
118
144
  token: readVillageToken,
119
- apiUrl: process.env.NEXT_PUBLIC_VILLAGE_API_URL,
120
145
  });
121
- defineVillageElements(village);
122
- return village;
123
146
  }
124
147
 
125
148
  // Render with key={user.id}, so another user gets another client.
126
149
  export function WarmIntros({ company }: { company: string }) {
127
- useState(createUserVillage); // one client for this user, created once
150
+ const [village] = useState(createUserVillage); // one client for this user, created once
151
+ const canRenderElements = useVillageElements(village);
152
+ if (!canRenderElements) return null;
128
153
  return (
129
154
  <>
130
155
  <village-connect />
@@ -134,16 +159,16 @@ export function WarmIntros({ company }: { company: string }) {
134
159
  }
135
160
  ```
136
161
 
137
- The SDK calls `token` once, and again after a 401. `village.connect()` opens Village's network-sync modal; with test keys it connects the test user instead, with no modal. `secretKey` must never reach the browser: no `NEXT_PUBLIC_` prefix, and `createVillageApp` throws if it runs in one.
162
+ The SDK calls `token` once, then refreshes it and retries once after a 401 mapped to `unauthorized`. `village.connect()` opens Village's network-sync modal; with test keys it connects the test user instead, with no modal. `secretKey` must never reach the browser: no `NEXT_PUBLIC_` prefix, and `createVillageApp` throws if it runs in one.
138
163
 
139
164
  ### Plain HTML, no build step
140
165
 
141
166
  ```html
142
167
  <script type="module">
143
- import { createVillage } from 'https://esm.sh/@villagehq/sdk@0.2.0-beta.0';
144
- import { defineVillageElements } from 'https://esm.sh/@villagehq/sdk@0.2.0-beta.0/elements';
168
+ import { createVillage } from 'https://esm.sh/@villagehq/sdk@0.2.0-beta.2';
169
+ import { defineVillageElements } from 'https://esm.sh/@villagehq/sdk@0.2.0-beta.2/elements';
145
170
 
146
- const village = createVillage({ publishableKey: 'pk_test_…' }); // a publishable key is public
171
+ const village = createVillage({ publishableKey: 'pk_test_village_demo_user' }); // a publishable key is public
147
172
  defineVillageElements(village);
148
173
  </script>
149
174
 
@@ -167,9 +192,86 @@ if (status === 'ready' || status === 'needs_attention') {
167
192
  }
168
193
  ```
169
194
 
170
- The user still connects once in a browser (`village.connect()`), because only they can sync their network. `await member.user.ready({ timeoutMs })` waits for it in a job. Payer `user` apps have no server access.
195
+ With real keys, the user still connects once in a browser (`village.connect()`), because only they can sync their network. `await member.user.ready({ timeoutMs })` waits for it in a job. Payer `user` apps have no server access.
196
+
197
+ ## 5. Verify it works
198
+
199
+ Run the actual page on localhost with the public demo keys before you say you're done:
171
200
 
172
- ## 4. User states
201
+ - [ ] The page renders `<village-connect>` with `state="signed_out"` (payer `user`) or `state="not_connected"` (payer `app`). Check the attribute in the DOM.
202
+ - [ ] Clicking it connects:
203
+ - payer `user`: the synthetic user connects without a Village login or account;
204
+ - payer `app`: with test keys, no modal opens: the test user connects at once. (On your server, `villageApp.test.connectUser({ id, email })` does the same; the page notices on its next `village.user.get()` or reload.)
205
+ - [ ] `<village-connect>` goes `syncing`, then `ready` within about 10 s. The elements wait without asking for paths meanwhile; if your own REST call gets `409 network_not_synced` during this sync, it is recoverable: call again once `GET /v2/user/status` reads `ready`.
206
+ - [ ] No Village account, human approval, card, or real network sync was needed for this demo.
207
+ - [ ] `<village-facepile company="stripe.com">` ends in `found` or `none`. Try 5 domains: the test network has paths for about 70% of them.
208
+ - [ ] Clicking an item in `<village-paths>` fires `village-path-select` with a `person`.
209
+ - [ ] Payer `user`: after `village.signOut()`, the elements go back to `signed_out`.
210
+ - [ ] The browser console has no hydration warning, no CSP errors (see the reference) and no `VillageError`.
211
+ - [ ] `VILLAGE_SECRET_KEY` appears only in server code, and `.env.local` is in `.gitignore`.
212
+
213
+ A script check for payer `app`:
214
+
215
+ ```ts
216
+ // scripts/check-village.ts
217
+ import { createVillageApp } from '@villagehq/sdk/server';
218
+
219
+ const villageApp = createVillageApp({ secretKey: process.env.VILLAGE_SECRET_KEY ?? '' });
220
+ const user = { id: 'test-user-1', email: 'test@example.com' };
221
+ await villageApp.test.connectUser(user);
222
+ const member = villageApp.as(user);
223
+ await member.user.ready({ timeoutMs: 30_000 });
224
+ console.log(await member.companies.checkPaths('stripe.com'));
225
+ ```
226
+
227
+ ## 6. Errors
228
+
229
+ Wrapped SDK calls throw `VillageError` with `code`, `message`, `status`, `requestId`, `traceId` and `docsUrl` (a link to the row below). Caller cancellation can throw `AbortError`; raw `village.api` calls use the OpenAPI client response/error contract. `status`, `requestId`, `traceId`, `payer`, `topupUrl` and `syncUrl` can be null. `isVillageError(error, 'out_of_credits')` narrows it. `error.isExpected` is `true` for states your UI should show rather than report; `error.isRetryable` for transient failures; the flag does not retry a request.
230
+
231
+ | `code` | Cause | Fix |
232
+ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
233
+ | <a id="errors-signed_out"></a>`signed_out` | Payer `user`: no session in this browser | Render a button that calls `village.connect()` |
234
+ | <a id="errors-app_access_revoked"></a>`app_access_revoked` | The user removed your app from Village | Same as `signed_out`: the SDK already cleared the session |
235
+ | <a id="errors-not_connected"></a>`not_connected` | Payer `app`: the user hasn't linked Village | Call `village.connect()` from a click |
236
+ | <a id="errors-not_synced"></a>`not_synced` | HTTP 409 `network_not_synced`: the first network sync hasn't finished | Recoverable. Show progress; stores and elements load on their own once the network is `ready` |
237
+ | <a id="errors-out_of_credits"></a>`out_of_credits` | Out of credits. `error.payer` says whose when provided | `user`: link `error.topupUrl` if present. `app`: top up in the dashboard; payer can be null |
238
+ | <a id="errors-unauthorized"></a>`unauthorized` | Invalid key or token, or the app is inactive (live keys need a card for payer `app`) | `user`: connect again. `app`: check keys, token route and live billing; callback tokens retry once |
239
+ | <a id="errors-user_required"></a>`user_required` | A payer `user` app called Village without a signed-in user | Call `village.connect()` first; don't mint tokens for payer `user` apps |
240
+ | <a id="errors-test_mode_unsupported"></a>`test_mode_unsupported` | Test keys don't cover this endpoint | Use the endpoints in this guide, or live keys |
241
+ | <a id="errors-forbidden"></a>`forbidden` | The key or token can't do this | Read `error.message`; changing payer needs support once users exist |
242
+ | <a id="errors-popup_blocked"></a>`popup_blocked` | The browser blocked the sign-in popup | Call `village.connect()` directly in a click handler, not after an `await` |
243
+ | <a id="errors-token_failed"></a>`token_failed` | Your `token` function threw | Check your token route; `error.cause` has the original error |
244
+ | <a id="errors-validation"></a>`validation` | The request is invalid | See `error.fields` |
245
+ | <a id="errors-bad_request"></a>`bad_request` | The API refused the request, or the SDK was misconfigured | Read `error.message` |
246
+ | <a id="errors-not_found"></a>`not_found` | The requested resource was not found | Initial `paths()` lookups return `null`; otherwise handle the missing resource |
247
+ | <a id="errors-conflict"></a>`conflict` | The request conflicts with the current state | Read `error.message` and resolve the conflict |
248
+ | <a id="errors-rate_limited"></a>`rate_limited` | Too many requests | Back off and retry |
249
+ | <a id="errors-server"></a>`server` | Village failed | Retry later; send `requestId` to support |
250
+ | <a id="errors-timeout"></a>`timeout` | Village took too long (searches can take a minute) | Retry, or pass a larger `{ timeoutMs }` |
251
+ | <a id="errors-network"></a>`network` | Couldn't reach Village | Check connectivity and CSP `connect-src` |
252
+ | <a id="errors-invalid_response"></a>`invalid_response` | Village returned something the SDK couldn't read | Update the SDK; report `requestId` |
253
+
254
+ ## 7. Going live
255
+
256
+ After the local demo passes, create your own Village app:
257
+
258
+ ```bash
259
+ npx @villagehq/sdk@0.2.0-beta.2 init --name "Acme" --payer user # or --payer app
260
+ ```
261
+
262
+ `init` defaults to `http://localhost:3000`. If your local app uses another port, pass its actual origin, for example `--origin http://localhost:3010`.
263
+
264
+ This is the first step that needs a human with a Village account. `init` prints and opens an approval URL; ask the human to sign in there and approve the app. It writes your app's test keys to `.env.local`, runs a smoke test, and prints next steps and dashboard links without printing keys. If replacing the demo keys already in `.env.local`, `--yes` allows replacement without a terminal prompt.
265
+
266
+ In the Village dashboard at https://village.ai/platform, register and approve the app's HTTPS deployment origin before using live keys. Payer `app` also needs a card added at the billing link from `init`; payer `user` needs no app card. Use your deployment's secret/environment settings to replace test keys with the app's live publishable key and, for payer `app`, live server secret key. Never request or paste live secrets in chat.
267
+
268
+ Live keys require an approved HTTPS origin; they do not work on HTTP localhost. Live users connect and sync their real networks: payer `user` signs in through the Village popup, while payer `app` uses your signed-in user's id and email and opens the network-sync modal. Verify the deployed flow from the approved origin before launch.
269
+
270
+ CLI flags: `--name`, `--origin`, `--payer app|user`, `--yes` (`-y`), `--help` (`-h`).
271
+
272
+ ## 8. Reference
273
+
274
+ ### User states
173
275
 
174
276
  `village.user.get()` or `village.user.subscribe(({ user, error }) => …)`; `user.status` is one of:
175
277
 
@@ -183,9 +285,9 @@ The user still connects once in a browser (`village.connect()`), because only th
183
285
 
184
286
  `isNetworkReady(user)` is `true` for `ready` and `needs_attention`. Call `connect()` from a click: browsers block popups otherwise.
185
287
 
186
- ## 5. Elements
288
+ ### Elements
187
289
 
188
- Register once with `defineVillageElements(village)` (from `@villagehq/sdk/elements`).
290
+ Register with `defineVillageElements(village)` (from `@villagehq/sdk/elements`). In React, use `useVillageElements(village)` instead and render the elements once it returns `true`, as in the Next.js code above. Registering another client switches every element on the page to it.
189
291
 
190
292
  | Element | Attributes | Shows |
191
293
  | -------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
@@ -238,7 +340,7 @@ Events (bubbling, composed `CustomEvent`s):
238
340
  | `village-connected` | `{ user }`, after `connect()` leaves the user connected (not when they close the popup or modal without connecting) |
239
341
  | `village-error` | `{ error }`, when `connect()` fails, e.g. `popup_blocked` |
240
342
 
241
- **React 19 + Tailwind.** `import type {} from '@villagehq/sdk/react'` adds the JSX types. Events are `on` + the event name:
343
+ **React 19 + Tailwind.** Importing from `@villagehq/sdk/react` (or `import type {} from '@villagehq/sdk/react'`) adds the JSX types. Events are `on` + the event name; they work on elements rendered after `useVillageElements` returns `true`:
242
344
 
243
345
  ```tsx
244
346
  <village-facepile
@@ -252,9 +354,9 @@ Events (bubbling, composed `CustomEvent`s):
252
354
  </village-facepile>
253
355
  ```
254
356
 
255
- React 18 can't listen to custom element events; use the stores (section 6). Elements render in the browser only, which is fine: this data is per user.
357
+ React 18 can't listen to custom element events; use the stores below. Elements render in the browser only, which is fine: this data is per user.
256
358
 
257
- ## 6. Stores, for your own UI
359
+ ### Stores, for your own UI
258
360
 
259
361
  Every element is built on a store. Use them for fully custom UI in any framework.
260
362
 
@@ -287,7 +389,7 @@ What the stores do for you:
287
389
  - **Shared**: the same company returns the same store, so 50 badges make one fetch.
288
390
  - **Batched**: every `check` asked for in the same tick goes out as one request (100 companies each).
289
391
  - **Credit-safe**: a store fetches on its first subscriber and keeps its result after the last one leaves. It never retries on its own; `refresh()` does.
290
- - **Live**: a `not_synced` store fetches again when the network turns ready; every store resets on sign-out.
392
+ - **Live**: while the first sync runs, a store waits as `not_synced` without asking Village for paths, then fetches once the network turns ready; every store resets on sign-out.
291
393
 
292
394
  React:
293
395
 
@@ -302,7 +404,7 @@ function IntroBadge({ company }: { company: string }) {
302
404
  }
303
405
  ```
304
406
 
305
- Also `useVillageUser(village)` → `{ user, status, isLoading, error, refresh }`, `useVillageConnect(village)` → `{ connect, isConnecting }`, and `<VillageGate village fallback>`.
407
+ Also `useVillageElements(village)` → `true` once the elements can render (see section 4), `useVillageUser(village)` → `{ user, status, isLoading, error, refresh }`, `useVillageConnect(village)` → `{ connect, isConnecting }`, and `<VillageGate village fallback>`.
306
408
 
307
409
  Vue:
308
410
 
@@ -335,9 +437,9 @@ village.paths.check('stripe.com').subscribe((state) => {
335
437
  });
336
438
  ```
337
439
 
338
- ## 7. Other calls
440
+ ### Other calls
339
441
 
340
- Each returns once, and throws a `VillageError`:
442
+ Wrapped calls return a result or throw `VillageError` (caller cancellation can throw `AbortError`):
341
443
 
342
444
  ```ts
343
445
  await village.people.search('CTOs in Berlin'); // a Page: results, hasMore, next(), for await
@@ -346,82 +448,13 @@ await village.people.paths('satyanadella'); // null when Village doesn't know th
346
448
  await village.companies.paths('stripe.com', { degrees: [2, 3] });
347
449
  await village.people.enrich('satyanadella');
348
450
  await village.companies.enrich('stripe.com');
349
- const { data } = await village.api.GET('/v2/lists'); // any v2 endpoint, typed
350
451
  ```
351
452
 
352
- Calls that spend credits shouldn't retry on their own; in React Query that's `staleTime: Infinity, retry: false`. `createVillage({ onRequest })` reports every request (`operation`, `status`, `durationMs`, `requestId`, `error`) for your monitoring.
353
-
354
- ## 8. Verify it works
453
+ Raw `village.api` access is typed but does not wrap failures as `VillageError`. Coding agents should use the calls shown on this page for this integration.
355
454
 
356
- Run this with the test keys before you say you're done:
357
-
358
- - [ ] `npx @villagehq/sdk init` ended with `✓ Smoke test passed`.
359
- - [ ] The page renders `<village-connect>` with `state="signed_out"` (payer `user`) or `state="not_connected"` (payer `app`). Check the attribute in the DOM.
360
- - [ ] Clicking it connects:
361
- - payer `user`: a popup reads "Test mode: connect a test user"; click Connect;
362
- - payer `app`: with test keys, no modal opens: the test user connects at once. (On your server, `villageApp.test.connectUser({ id, email })` does the same; the page notices on its next `village.user.get()` or reload.)
363
- - [ ] `<village-connect>` goes `syncing`, then `ready` within about 10 s.
364
- - [ ] `<village-facepile company="stripe.com">` ends in `found` or `none`. Try 5 domains: the test network has paths for about 70% of them.
365
- - [ ] Clicking an item in `<village-paths>` fires `village-path-select` with a `person`.
366
- - [ ] Payer `user`: after `village.signOut()`, the elements go back to `signed_out`.
367
- - [ ] The browser console has no CSP errors (section 11) and no `VillageError`.
368
- - [ ] `VILLAGE_SECRET_KEY` appears only in server code, and `.env.local` is in `.gitignore`.
369
-
370
- A script check for payer `app`:
371
-
372
- ```ts
373
- import { createVillageApp } from '@villagehq/sdk/server';
374
-
375
- const villageApp = createVillageApp({ secretKey: process.env.VILLAGE_SECRET_KEY ?? '' });
376
- const user = { id: 'test-user-1', email: 'test@example.com' };
377
- await villageApp.test.connectUser(user);
378
- const member = villageApp.as(user);
379
- await member.user.ready({ timeoutMs: 30_000 });
380
- console.log(await member.companies.checkPaths('stripe.com'));
381
- ```
455
+ Calls that spend credits shouldn't retry on their own; in React Query that's `staleTime: Infinity, retry: false`. `createVillage({ onRequest })` reports every request (`operation`, `status`, `durationMs`, `requestId`, `error`) for your monitoring.
382
456
 
383
- ## 9. What to ask your human
384
-
385
- Ask exactly these, and nothing else:
386
-
387
- 1. "Open the link `npx @villagehq/sdk init` printed, sign in to Village, and approve the app." (Once.)
388
- 2. "Which URLs will the app run on in production?" They must be `https://`. Add them in the Village dashboard (`/platform`) before going live.
389
- 3. Payer `app` only: "Which auth system tells us the signed-in user's id and email?" (for the token route).
390
- 4. Payer `app` only, before going live: "Add a card at the billing link `init` printed." Live keys turn on after.
391
- 5. "Where in the product should warm intros appear?"
392
-
393
- Don't ask for keys (`init` writes them), a test user (test mode has one), or a synced network (the test network is synthetic).
394
-
395
- **Going live:** swap the test keys for the live keys from the dashboard (`pk_live_…`, plus `sk_…` for payer `app`). Live keys accept only `https://` origins (no localhost). Live sessions and tokens need real users who sign in and sync their own network.
396
-
397
- ## 10. Errors
398
-
399
- Every failure is a `VillageError` with `code`, `message`, `status`, `requestId`, `traceId` and `docsUrl` (a link to the row below). `isVillageError(error, 'out_of_credits')` narrows it. `error.isExpected` is `true` for states your UI should show rather than report; `error.isRetryable` for transient failures.
400
-
401
- | `code` | Cause | Fix |
402
- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
403
- | <a id="errors-signed_out"></a>`signed_out` | Payer `user`: no session in this browser | Render a button that calls `village.connect()` |
404
- | <a id="errors-app_access_revoked"></a>`app_access_revoked` | The user removed your app from Village | Same as `signed_out`: the SDK already cleared the session |
405
- | <a id="errors-not_connected"></a>`not_connected` | Payer `app`: the user hasn't linked Village | Call `village.connect()` from a click |
406
- | <a id="errors-not_synced"></a>`not_synced` | The network sync hasn't finished | Show progress; stores fetch again when it's ready. `error.syncUrl` links to the sync |
407
- | <a id="errors-out_of_credits"></a>`out_of_credits` | Out of credits. `error.payer` says whose | `user`: link `error.topupUrl`. `app`: top up at `/platform/settings/billing` |
408
- | <a id="errors-unauthorized"></a>`unauthorized` | Invalid key or token, or the app is inactive (live keys need a card for payer `app`) | Check the env vars; run `npx @villagehq/sdk init` again; add a card |
409
- | <a id="errors-user_required"></a>`user_required` | A payer `user` app called Village without a signed-in user | Call `village.connect()` first; don't mint tokens for payer `user` apps |
410
- | <a id="errors-test_mode_unsupported"></a>`test_mode_unsupported` | Test keys don't cover this endpoint | Use the endpoints in this guide, or live keys |
411
- | <a id="errors-forbidden"></a>`forbidden` | The key or token can't do this | Read `error.message`; changing payer needs support once users exist |
412
- | <a id="errors-popup_blocked"></a>`popup_blocked` | The browser blocked the sign-in popup | Call `village.connect()` directly in a click handler, not after an `await` |
413
- | <a id="errors-token_failed"></a>`token_failed` | Your `token` function threw | Check your token route; `error.cause` has the original error |
414
- | <a id="errors-validation"></a>`validation` | The request is invalid | See `error.fields` |
415
- | <a id="errors-bad_request"></a>`bad_request` | The API refused the request, or the SDK was misconfigured | Read `error.message` |
416
- | <a id="errors-not_found"></a>`not_found` | Village doesn't know this person or company | Show nothing; `paths()` already returns `null` |
417
- | <a id="errors-conflict"></a>`conflict` | Already done | Usually safe to ignore |
418
- | <a id="errors-rate_limited"></a>`rate_limited` | Too many requests | Back off and retry |
419
- | <a id="errors-server"></a>`server` | Village failed | Retry later; send `requestId` to support |
420
- | <a id="errors-timeout"></a>`timeout` | Village took too long (searches can take a minute) | Retry, or pass a larger `{ timeoutMs }` |
421
- | <a id="errors-network"></a>`network` | Couldn't reach Village | Check connectivity and CSP `connect-src` |
422
- | <a id="errors-invalid_response"></a>`invalid_response` | Village returned something the SDK couldn't read | Update the SDK; report `requestId` |
423
-
424
- ## 11. Content Security Policy and popups
457
+ ### Content Security Policy and popups
425
458
 
426
459
  ```
427
460
  connect-src https://api.village.ai;
@@ -430,4 +463,4 @@ frame-src https://village.ai; # payer app: the sync modal
430
463
  script-src https://esm.sh; # only for the CDN import
431
464
  ```
432
465
 
433
- Staging: `https://api-stg.village.ai` and `https://staging.village.ai`. The payer `user` sign-in is a popup on `https://village.ai/connect`. If your site sends `Cross-Origin-Opener-Policy: same-origin`, the popup can't report back: use `same-origin-allow-popups`.
466
+ The payer `user` sign-in is a popup on `https://village.ai/connect`. If your site sends `Cross-Origin-Opener-Policy: same-origin`, the popup can't report back: use `same-origin-allow-popups`.
package/README.md CHANGED
@@ -1,35 +1,71 @@
1
1
  # @villagehq/sdk
2
2
 
3
- Put Village warm introductions in your product: who your users know at a company, and who can introduce them.
3
+ Start at https://docs.village.ai/build.
4
4
 
5
- **Building an integration? Follow the guide: https://docs.village.ai/build** (also shipped as [`AGENTS.md`](./AGENTS.md) in this package, matching your installed version).
5
+ Put Village warm introductions in your product: who your users know at a company, and who can introduce them. The complete guide also ships as [`AGENTS.md`](./AGENTS.md), matching your installed SDK version.
6
6
 
7
7
  ```bash
8
- npm install @villagehq/sdk
9
- npx @villagehq/sdk init # creates your app, writes test keys to .env.local, runs a smoke test
8
+ npm install @villagehq/sdk@0.2.0-beta.2
10
9
  ```
11
10
 
12
- ```ts
13
- import { createVillage } from '@villagehq/sdk';
14
- import { defineVillageElements } from '@villagehq/sdk/elements';
11
+ ## Demo in two minutes (Next.js App Router)
12
+
13
+ Add the public demo key to `.env.local`. It works on `localhost` against a synthetic network, with no account:
15
14
 
16
- const village = createVillage({ publishableKey: 'pk_test_…' });
17
- defineVillageElements(village);
15
+ ```dotenv
16
+ NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY=pk_test_village_demo_user
18
17
  ```
19
18
 
20
- ```html
21
- <village-connect></village-connect> <village-facepile company="stripe.com"></village-facepile>
19
+ ```tsx
20
+ // components/warm-intros.tsx
21
+ 'use client';
22
+
23
+ import { createVillage } from '@villagehq/sdk';
24
+ import { useVillageElements } from '@villagehq/sdk/react';
25
+
26
+ const village = createVillage({
27
+ publishableKey: process.env.NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY ?? '',
28
+ });
29
+
30
+ export function WarmIntros({ company = 'stripe.com' }: { company?: string }) {
31
+ const canRenderElements = useVillageElements(village); // registers the elements after hydration
32
+ if (!canRenderElements) return null;
33
+
34
+ return (
35
+ <section>
36
+ <village-connect />
37
+ <village-facepile company={company} />
38
+ <village-paths company={company} />
39
+ </section>
40
+ );
41
+ }
22
42
  ```
23
43
 
24
- | Import | What |
25
- | ------------------------- | ----------------------------------------------------------------------------------------------------------------- |
26
- | `@villagehq/sdk` | `createVillage`: user status, `connect()`, stores (`village.paths.*`), search, paths, enrichment, the typed `api` |
27
- | `@villagehq/sdk/elements` | `<village-connect>`, `<village-facepile>`, `<village-paths>` web components |
28
- | `@villagehq/sdk/react` | `useVillage(store)`, `useVillageUser`, `useVillageConnect`, `<VillageGate>`, JSX types for the elements |
29
- | `@villagehq/sdk/server` | `createVillageApp({ secretKey })`: tokens, acting as a user, test users |
44
+ Render `<WarmIntros />` in `app/page.tsx`, restart `npm run dev` and open the page. You should see a **Connect Village** button. Click it: no login opens, it shows "Syncing your network…" for about 5 seconds, then "Village connected", a facepile with a strength label, and a list of people who can introduce you. The people are synthetic. About 30% of domains have no paths and show "No warm intros yet". The console stays free of errors.
45
+
46
+ There is no `Village.init`, `Village.findPaths` or default export, and `@village-ai/sdk` is another package: use `createVillage`, `village.paths.*` and the `<village-*>` elements.
30
47
 
31
- The npm package `village` is unrelated to Village.
48
+ Start locally with the public synthetic demo keys from the guide. No Village account, human approval, card, or real network is needed. Follow the chosen payer mode; if it is unclear, ask who pays asynchronously and verify the payer `user` demo while waiting. Run `npx @villagehq/sdk@0.2.0-beta.2 init` when you reach Going live.
49
+
50
+ | Import | What |
51
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
52
+ | `@villagehq/sdk` | `createVillage`: user status, `connect()`, stores (`village.paths.*`), search, paths, enrichment, the typed `api` |
53
+ | `@villagehq/sdk/elements` | `<village-connect>`, `<village-facepile>`, `<village-paths>` web components |
54
+ | `@villagehq/sdk/react` | `useVillageElements`, `useVillage(store)`, `useVillageUser`, `useVillageConnect`, `<VillageGate>`, JSX types for the elements |
55
+ | `@villagehq/sdk/server` | `createVillageApp({ secretKey })`: tokens, acting as a user, test users |
32
56
 
33
57
  ## Developing
34
58
 
35
- `pnpm test`, `pnpm type-check`, and `pnpm check:package` (builds, then type-checks a consumer against the output). Regenerate API types with `pnpm run generate` (live API) or `pnpm run generate:local` (this repo's backend). Endpoints not in the spec yet are typed by hand in `src/core/contract-types.ts`; delete them from there once they're generated.
59
+ Run from the monorepo root:
60
+
61
+ ```bash
62
+ pnpm exec turbo run build test type-check --filter=@villagehq/sdk
63
+ pnpm --filter @villagehq/sdk exec tsc -p tests/consumer/tsconfig.json
64
+ node apps/docs/scripts/sync-build-guide.mjs --check
65
+ ```
66
+
67
+ `AGENTS.md` is the canonical guide. The SDK version placeholder is replaced with the version from `package.json` when generating `apps/docs/build.mdx` and staging the npm package. After editing the guide, run `pnpm run docs:sync-build-guide`.
68
+
69
+ The build stages a complete npm package at `dist/package`, including a resolved root `AGENTS.md`, without modifying the source guide. Publish or pack that directory (`npm publish ./dist/package --ignore-scripts` or `npm pack ./dist/package --ignore-scripts`). `pnpm publish` also uses that directory through `publishConfig.directory`.
70
+
71
+ Regenerate API types with `pnpm --filter @villagehq/sdk run generate` (live API) or `generate:local` (this repo's backend). Endpoints not in the spec yet are typed by hand in `src/core/contract-types.ts`; delete them from there once generated.
@@ -253,7 +253,9 @@ async function smokeTestAppPays(request, secretKey, sleep, log) {
253
253
 
254
254
  async function smokeTestUserPays(request, publishableKey, origin, log) {
255
255
  const query = new URLSearchParams({ publishable_key: publishableKey, origin });
256
- const { response, body } = await request('GET', `/v2/auth/connect/app?${query}`);
256
+ const { response, body } = await request('GET', `/v2/auth/connect/app?${query}`, {
257
+ headers: { origin },
258
+ });
257
259
  if (!response.ok) throw new CliError(`The connect check failed (${describeFailure(response, body)}).`);
258
260
  const payer = unwrap(body)?.app?.payer;
259
261
  if (payer !== 'user') throw new CliError(`The app is set to payer "${payer}", expected "user".`);
@@ -55,29 +55,14 @@ type PathsStoresOptions<PersonPaths, CompanyPathsPage> = {
55
55
  company: (page: CompanyPathsPage) => boolean;
56
56
  };
57
57
  watchUser: () => () => void;
58
+ isFirstSyncRunning: () => Promise<boolean>;
58
59
  };
59
60
  export type CompanyPathsStoreOptions = {
60
61
  degrees?: Array<1 | 2 | 3>;
61
62
  limit?: number;
62
63
  };
63
- export declare function createPathsStores<PersonPaths, CompanyPathsPage>({ payer, checkCompany, personPaths, companyPaths, hasPaths, watchUser, }: PathsStoresOptions<PersonPaths, CompanyPathsPage>): {
64
- check: (company: string) => import("./store.js").VillageStore<{
65
- kind: "signed_out";
66
- } | {
67
- kind: "not_connected";
68
- } | {
69
- kind: "not_synced";
70
- } | {
71
- kind: "out_of_credits";
72
- payer: Payer;
73
- topupUrl: string | null;
74
- } | LoadingState | NoneState | ErrorState | {
75
- kind: "found";
76
- people: FacepilePerson[];
77
- more: number;
78
- strength: number | null;
79
- strength_label: string | null;
80
- }>;
64
+ export declare function createPathsStores<PersonPaths, CompanyPathsPage>({ payer, checkCompany, personPaths, companyPaths, hasPaths, watchUser, isFirstSyncRunning, }: PathsStoresOptions<PersonPaths, CompanyPathsPage>): {
65
+ check: (company: string) => import("./store.js").VillageStore<PathsCheckState>;
81
66
  person: (personInput: string) => import("./store.js").VillageStore<PathsState<PersonPaths>>;
82
67
  company: (companyInput: string, { degrees, limit }?: CompanyPathsStoreOptions) => import("./store.js").VillageStore<PathsState<CompanyPathsPage>>;
83
68
  reloadAll: () => void;
@@ -4,6 +4,7 @@ import { createStore } from './store.js';
4
4
  const BULK_CHECK_LIMIT = 100;
5
5
  const LOADING = { kind: 'loading' };
6
6
  const NONE = { kind: 'none' };
7
+ const NOT_SYNCED = { kind: 'not_synced' };
7
8
  const BLOCKED_STATE_BY_CODE = {
8
9
  signed_out: { kind: 'signed_out' },
9
10
  app_access_revoked: { kind: 'signed_out' },
@@ -61,7 +62,7 @@ function toCheckState(check) {
61
62
  function waitsForNetwork(state) {
62
63
  return state.kind === 'not_synced';
63
64
  }
64
- export function createPathsStores({ payer, checkCompany, personPaths, companyPaths, hasPaths, watchUser, }) {
65
+ export function createPathsStores({ payer, checkCompany, personPaths, companyPaths, hasPaths, watchUser, isFirstSyncRunning, }) {
65
66
  const checks = new Map();
66
67
  const people = new Map();
67
68
  const companies = new Map();
@@ -84,11 +85,17 @@ export function createPathsStores({ payer, checkCompany, personPaths, companyPat
84
85
  }
85
86
  function check(company) {
86
87
  const identifier = toCompanyIdentifier(company);
87
- return cached(checks, JSON.stringify(identifier), LOADING, () => checkCompany(identifier).then(toCheckState, toFailureState));
88
+ return cached(checks, JSON.stringify(identifier), LOADING, async () => {
89
+ if (await isFirstSyncRunning())
90
+ return NOT_SYNCED;
91
+ return checkCompany(identifier).then(toCheckState, toFailureState);
92
+ });
88
93
  }
89
94
  function person(personInput) {
90
95
  const identifier = toPersonIdentifier(personInput);
91
96
  return cached(people, JSON.stringify(identifier), LOADING, async () => {
97
+ if (await isFirstSyncRunning())
98
+ return NOT_SYNCED;
92
99
  try {
93
100
  const paths = await personPaths(personInput);
94
101
  if (paths === null || !hasPaths.person(paths))
@@ -104,6 +111,8 @@ export function createPathsStores({ payer, checkCompany, personPaths, companyPat
104
111
  const identifier = toCompanyIdentifier(companyInput);
105
112
  const key = JSON.stringify([identifier, degrees ?? null, limit ?? null]);
106
113
  return cached(companies, key, LOADING, async () => {
114
+ if (await isFirstSyncRunning())
115
+ return NOT_SYNCED;
107
116
  try {
108
117
  const page = await companyPaths(companyInput, { ...(degrees ? { degrees } : {}), ...(limit ? { limit } : {}) });
109
118
  if (page === null || !hasPaths.company(page))
@@ -1,5 +1,4 @@
1
1
  import type { components, paths } from '../generated/api.js';
2
- import type { Payer } from './api-types.js';
3
2
  import { isVillageError, VillageError } from './errors.js';
4
3
  import { type Page } from './pages.js';
5
4
  import { type SessionStorage } from './session.js';
@@ -78,30 +77,7 @@ export declare function createVillage(options: VillageOptions): {
78
77
  connect: () => Promise<VillageUser>;
79
78
  signOut: () => Promise<void>;
80
79
  paths: {
81
- check: (company: string) => import("./store.js").VillageStore<{
82
- kind: "signed_out";
83
- } | {
84
- kind: "not_connected";
85
- } | {
86
- kind: "not_synced";
87
- } | {
88
- kind: "out_of_credits";
89
- payer: Payer;
90
- topupUrl: string | null;
91
- } | {
92
- kind: "loading";
93
- } | {
94
- kind: "none";
95
- } | {
96
- kind: "error";
97
- error: VillageError;
98
- } | {
99
- kind: "found";
100
- people: import("./api-types.js").FacepilePerson[];
101
- more: number;
102
- strength: number | null;
103
- strength_label: string | null;
104
- }>;
80
+ check: (company: string) => import("./store.js").VillageStore<import("./paths-stores.js").PathsCheckState>;
105
81
  person: (personInput: string) => import("./store.js").VillageStore<import("./paths-stores.js").PathsState<{
106
82
  person: components["schemas"]["PersonInfo"];
107
83
  summary: components["schemas"]["PathSummary"];
@@ -13,6 +13,7 @@ export { linkedinSlug, linkedinUrl } from './identifiers.js';
13
13
  export { openSyncModal } from './sync-modal.js';
14
14
  export { isNetworkReady } from './user.js';
15
15
  const DEFAULT_APP_URL = 'https://village.ai';
16
+ const DEMO_KEYS_URL = 'https://docs.village.ai/build#2-public-demo-keys';
16
17
  const SEARCH_TIMEOUT_MS = 120_000;
17
18
  const PATHS_TIMEOUT_MS = 60_000;
18
19
  const TEST_MODE = 'test';
@@ -43,7 +44,7 @@ function requirePublishableKey(publishableKey) {
43
44
  return publishableKey;
44
45
  throw new VillageError({
45
46
  code: 'bad_request',
46
- message: 'Pass createVillage({ publishableKey }) when users pay, or { token } when your app pays. Run `npx @villagehq/sdk init` to get keys.',
47
+ message: `Pass createVillage({ publishableKey }) when users pay, or { token } when your app pays. To build locally, use the public demo key pk_test_village_demo_user (no account needed): ${DEMO_KEYS_URL}`,
47
48
  });
48
49
  }
49
50
  function signedOutError() {
@@ -103,11 +104,32 @@ function isTestModeToken(token) {
103
104
  }
104
105
  export function createVillage(options) {
105
106
  const { appUrl = DEFAULT_APP_URL, storage, publishableKey, token, ...transportOptions } = options;
107
+ const sessionStorage = storage ?? defaultSessionStorage();
106
108
  const payer = token === undefined ? 'user' : 'app';
107
109
  const { api, send, readToken, session } = token === undefined
108
- ? createUserPaysAuth(requirePublishableKey(publishableKey), storage ?? defaultSessionStorage(), transportOptions, endSession)
110
+ ? createUserPaysAuth(requirePublishableKey(publishableKey), sessionStorage, transportOptions, endSession)
109
111
  : createAppPaysAuth(token, transportOptions);
110
112
  let lastStatus = null;
113
+ let testUserId = null;
114
+ function readTestUserId() {
115
+ if (testUserId)
116
+ return testUserId;
117
+ const key = `village.test-user.${publishableKey}`;
118
+ try {
119
+ testUserId = sessionStorage.getItem(key);
120
+ }
121
+ catch {
122
+ // Blocked storage keeps this browser's identity in memory.
123
+ }
124
+ testUserId ||= crypto.randomUUID();
125
+ try {
126
+ sessionStorage.setItem(key, testUserId);
127
+ }
128
+ catch {
129
+ // The in-memory identity still survives reconnecting and signing out.
130
+ }
131
+ return testUserId;
132
+ }
111
133
  function handleUserChange(nextUser) {
112
134
  const wasReady = lastStatus === 'ready' || lastStatus === 'needs_attention';
113
135
  lastStatus = nextUser.status;
@@ -128,6 +150,25 @@ export function createVillage(options) {
128
150
  }
129
151
  }
130
152
  const { store: user, replace: replaceUser } = createUserStore(readUser, handleUserChange);
153
+ let sharedUserRead = null;
154
+ // Stores that load together share one status read.
155
+ function readUserOnce() {
156
+ sharedUserRead ??= user
157
+ .get()
158
+ .catch(() => null)
159
+ .finally(() => {
160
+ sharedUserRead = null;
161
+ });
162
+ return sharedUserRead;
163
+ }
164
+ async function isFirstSyncRunning() {
165
+ if (isNetworkReady(user.snapshot().user))
166
+ return false;
167
+ await readUserOnce();
168
+ // The snapshot, not this read: a newer read may already have found the network ready.
169
+ const latest = user.snapshot().user;
170
+ return latest?.status === 'syncing' && latest.network_updated_at === null;
171
+ }
131
172
  function endSession() {
132
173
  replaceUser(SIGNED_OUT_USER);
133
174
  pathStores.reloadAll();
@@ -146,6 +187,19 @@ export function createVillage(options) {
146
187
  pathStores.reloadWaiting();
147
188
  return user.get();
148
189
  }
190
+ if (publishableKey?.startsWith('pk_test_')) {
191
+ if (typeof window === 'undefined') {
192
+ throw new VillageError({ code: 'bad_request', message: 'village.connect() only runs in a browser.' });
193
+ }
194
+ const connectTransport = createTransport(transportOptions, ignoreFailure);
195
+ const { data } = await connectTransport.send('connect.complete', ({ signal }) => connectTransport.api.POST('/v2/auth/connect/complete', {
196
+ body: { publishable_key: publishableKey, origin: window.location.origin, test_user: readTestUserId() },
197
+ signal,
198
+ }));
199
+ session.save(data);
200
+ pathStores.reloadAll();
201
+ return user.get();
202
+ }
149
203
  await openConnectPopup({
150
204
  appUrl,
151
205
  publishableKey: requirePublishableKey(publishableKey),
@@ -234,6 +288,7 @@ export function createVillage(options) {
234
288
  },
235
289
  // A fresh listener each time: the user store keeps listeners in a Set.
236
290
  watchUser: () => user.subscribe(() => undefined),
291
+ isFirstSyncRunning,
237
292
  });
238
293
  async function joinGroup(inviteLink, options) {
239
294
  try {
@@ -132,9 +132,11 @@ function createElementClasses(requireVillage) {
132
132
  connectedCallback() {
133
133
  this.unbind();
134
134
  this.stopWatching = this.watch(requireVillage());
135
+ connectedElements.add(this);
135
136
  }
136
137
  disconnectedCallback() {
137
138
  this.unbind();
139
+ connectedElements.delete(this);
138
140
  }
139
141
  attributeChangedCallback() {
140
142
  if (this.stopWatching)
@@ -286,13 +288,16 @@ function createElementClasses(requireVillage) {
286
288
  };
287
289
  }
288
290
  let activeVillage = null;
291
+ const connectedElements = new Set();
289
292
  function requireVillage() {
290
293
  if (activeVillage)
291
294
  return activeVillage;
292
295
  throw new Error('Call defineVillageElements(village) before using Village elements.');
293
296
  }
294
297
  // Safe to import on a server: nothing touches the DOM until this runs in a browser.
298
+ // Call it again with another client (another signed-in user) and every element on the page switches to it.
295
299
  export function defineVillageElements(village) {
300
+ const isNewClient = activeVillage !== null && activeVillage !== village;
296
301
  activeVillage = village;
297
302
  if (typeof customElements === 'undefined')
298
303
  return;
@@ -300,4 +305,7 @@ export function defineVillageElements(village) {
300
305
  if (!customElements.get(tag))
301
306
  customElements.define(tag, elementClass);
302
307
  }
308
+ if (isNewClient)
309
+ for (const element of connectedElements)
310
+ element.connectedCallback();
303
311
  }
@@ -2,7 +2,14 @@ import { type DetailedHTMLProps, type HTMLAttributes, type ReactNode } from 'rea
2
2
  import type { VillageStore } from '../core/store.js';
3
3
  import { type VillageUser } from '../core/user.js';
4
4
  import type { Village } from '../core/village.js';
5
- import type { VillageConnectedDetail, VillageErrorDetail, VillagePathSelectDetail } from '../elements/elements.js';
5
+ import { type VillageConnectedDetail, type VillageErrorDetail, type VillagePathSelectDetail } from '../elements/elements.js';
6
+ /**
7
+ * Registers the Village elements for this client after hydration and returns true once they can
8
+ * render. Render `<village-*>` only when it does: React doesn't attach `on*` listeners to custom
9
+ * elements it hydrates from server HTML, an element defined before hydration causes a mismatch
10
+ * warning, and an element mounted before its client is registered would show the previous client's data.
11
+ */
12
+ export declare function useVillageElements(village: Village): boolean;
6
13
  export declare function useVillage<State>(store: VillageStore<State>): State;
7
14
  export declare function useVillageUser(village: Village): {
8
15
  user: VillageUser | null;
@@ -1,9 +1,24 @@
1
1
  'use client';
2
- import { useCallback, useState, useSyncExternalStore, } from 'react';
2
+ import { useCallback, useEffect, useState, useSyncExternalStore, } from 'react';
3
3
  import { isNetworkReady } from '../core/user.js';
4
+ import { defineVillageElements, } from '../elements/elements.js';
4
5
  function ignoreFailure() {
5
6
  return undefined;
6
7
  }
8
+ /**
9
+ * Registers the Village elements for this client after hydration and returns true once they can
10
+ * render. Render `<village-*>` only when it does: React doesn't attach `on*` listeners to custom
11
+ * elements it hydrates from server HTML, an element defined before hydration causes a mismatch
12
+ * warning, and an element mounted before its client is registered would show the previous client's data.
13
+ */
14
+ export function useVillageElements(village) {
15
+ const [registeredVillage, setRegisteredVillage] = useState(null);
16
+ useEffect(() => {
17
+ defineVillageElements(village);
18
+ setRegisteredVillage(village);
19
+ }, [village]);
20
+ return registeredVillage === village;
21
+ }
7
22
  export function useVillage(store) {
8
23
  const subscribe = useCallback((onChange) => store.subscribe(onChange), [store]);
9
24
  return useSyncExternalStore(subscribe, store.get, store.get);
@@ -1,5 +1,4 @@
1
1
  import type { paths } from '../generated/api.js';
2
- import { VillageError } from '../core/errors.js';
3
2
  import { type CallOptions, type TransportOptions } from '../core/transport.js';
4
3
  type AppUpdate = NonNullable<paths['/v2/app']['patch']['requestBody']>['content']['application/json'];
5
4
  export type TokenGrant = 'full' | 'public';
@@ -23,30 +22,7 @@ export declare function createVillageApp({ secretKey, appUrl, ...transportOption
23
22
  connect: () => Promise<import("../core/user.js").VillageUser>;
24
23
  signOut: () => Promise<void>;
25
24
  paths: {
26
- check: (company: string) => import("../core/store.js").VillageStore<{
27
- kind: "signed_out";
28
- } | {
29
- kind: "not_connected";
30
- } | {
31
- kind: "not_synced";
32
- } | {
33
- kind: "out_of_credits";
34
- payer: import("../core/api-types.js").Payer;
35
- topupUrl: string | null;
36
- } | {
37
- kind: "loading";
38
- } | {
39
- kind: "none";
40
- } | {
41
- kind: "error";
42
- error: VillageError;
43
- } | {
44
- kind: "found";
45
- people: import("../core/api-types.js").FacepilePerson[];
46
- more: number;
47
- strength: number | null;
48
- strength_label: string | null;
49
- }>;
25
+ check: (company: string) => import("../core/store.js").VillageStore<import("../core/paths-stores.js").PathsCheckState>;
50
26
  person: (personInput: string) => import("../core/store.js").VillageStore<import("../core/paths-stores.js").PathsState<{
51
27
  person: import("../generated/api.js").components["schemas"]["PersonInfo"];
52
28
  summary: import("../generated/api.js").components["schemas"]["PathSummary"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@villagehq/sdk",
3
- "version": "0.2.0-beta.0",
3
+ "version": "0.2.0-beta.2",
4
4
  "description": "Put Village warm intros in your app: intro paths, people and company search, drop-in web components, and a setup CLI.",
5
5
  "keywords": [
6
6
  "village",
@@ -51,15 +51,6 @@
51
51
  },
52
52
  "./package.json": "./package.json"
53
53
  },
54
- "scripts": {
55
- "build": "tsc -p tsconfig.build.json",
56
- "generate": "openapi-typescript https://api.village.ai/v2/docs/openapi.json -o src/generated/api.ts --default-non-nullable=false",
57
- "generate:local": "openapi-typescript ../../apps/backend-main/src/api/v2/docs/openapi.json -o src/generated/api.ts --default-non-nullable=false",
58
- "prepublishOnly": "pnpm run build",
59
- "test": "vitest run",
60
- "type-check": "tsc --noEmit",
61
- "check:package": "pnpm run build && tsc -p tests/consumer/tsconfig.json"
62
- },
63
54
  "dependencies": {
64
55
  "openapi-fetch": "^0.15.0"
65
56
  },
@@ -74,6 +65,7 @@
74
65
  "devDependencies": {
75
66
  "@testing-library/react": "^16.3.0",
76
67
  "@types/react": "^19.1.0",
68
+ "@types/react-dom": "^19.3.0",
77
69
  "jsdom": "^26.1.0",
78
70
  "openapi-typescript": "^7.10.0",
79
71
  "react": "^19.1.0",