@villagehq/sdk 0.1.0-beta.0 → 0.2.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +433 -0
- package/README.md +17 -236
- package/bin/village-sdk.d.mts +15 -0
- package/bin/village-sdk.mjs +380 -0
- package/dist/core/api-types.d.ts +7 -0
- package/dist/core/api-types.js +1 -0
- package/dist/core/connect-popup.d.ts +8 -0
- package/dist/core/connect-popup.js +85 -0
- package/dist/core/contract-types.d.ts +41 -0
- package/dist/core/contract-types.js +1 -0
- package/dist/core/errors.d.ts +6 -2
- package/dist/core/errors.js +33 -10
- package/dist/core/paths-stores.d.ts +86 -0
- package/dist/core/paths-stores.js +132 -0
- package/dist/core/session.d.ts +20 -0
- package/dist/core/session.js +121 -0
- package/dist/core/store.d.ts +18 -0
- package/dist/core/store.js +56 -0
- package/dist/core/transport.d.ts +4 -2
- package/dist/core/transport.js +8 -3
- package/dist/core/user.d.ts +15 -30
- package/dist/core/user.js +17 -4
- package/dist/core/village.d.ts +63 -39
- package/dist/core/village.js +165 -15
- package/dist/elements/elements.d.ts +31 -0
- package/dist/elements/elements.js +303 -0
- package/dist/generated/api.d.ts +1074 -46
- package/dist/react/react.d.ts +39 -28
- package/dist/react/react.js +12 -8
- package/dist/server/server.d.ts +63 -36
- package/dist/server/server.js +13 -2
- package/package.json +26 -2
package/AGENTS.md
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
# Build on Village
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
- Package: `@villagehq/sdk`. The npm package `village` belongs to another company; never install it.
|
|
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
|
+
|
|
8
|
+
## 1. Pick who pays
|
|
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.
|
|
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 |
|
|
18
|
+
|
|
19
|
+
## 2. Set up
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @villagehq/sdk
|
|
23
|
+
npx @villagehq/sdk init --name "Acme" --origin http://localhost:3000 --payer user # or --payer app
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`init` prints a URL and opens it. Your human signs in to Village and approves the app. Then `init`:
|
|
27
|
+
|
|
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.
|
|
31
|
+
|
|
32
|
+
It never prints a key. Don't paste keys into chat; read them from the environment.
|
|
33
|
+
|
|
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`.
|
|
35
|
+
|
|
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.
|
|
37
|
+
|
|
38
|
+
## 3. Code
|
|
39
|
+
|
|
40
|
+
### Payer `user`, Next.js App Router
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// lib/village.ts
|
|
44
|
+
'use client';
|
|
45
|
+
|
|
46
|
+
import { createVillage } from '@villagehq/sdk';
|
|
47
|
+
import { defineVillageElements } from '@villagehq/sdk/elements';
|
|
48
|
+
|
|
49
|
+
export const village = createVillage({
|
|
50
|
+
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
|
+
});
|
|
54
|
+
|
|
55
|
+
defineVillageElements(village); // does nothing on the server
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
// components/warm-intros.tsx
|
|
60
|
+
'use client';
|
|
61
|
+
|
|
62
|
+
import type {} from '@villagehq/sdk/react'; // JSX types for <village-*>
|
|
63
|
+
import '@/lib/village'; // registers the elements
|
|
64
|
+
|
|
65
|
+
export function WarmIntros({ company }: { company: string }) {
|
|
66
|
+
return (
|
|
67
|
+
<section>
|
|
68
|
+
<village-connect />
|
|
69
|
+
<village-facepile company={company} />
|
|
70
|
+
<village-paths company={company} onvillage-path-select={(event) => askForIntro(event.detail)} />
|
|
71
|
+
</section>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
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
|
+
|
|
78
|
+
### Payer `app`, Next.js App Router
|
|
79
|
+
|
|
80
|
+
Your server mints a Village token for your signed-in user:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// app/api/village/token/route.ts
|
|
84
|
+
import { createVillageApp } from '@villagehq/sdk/server';
|
|
85
|
+
|
|
86
|
+
const villageApp = createVillageApp({
|
|
87
|
+
secretKey: process.env.VILLAGE_SECRET_KEY ?? '',
|
|
88
|
+
apiUrl: process.env.VILLAGE_API_URL,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export async function POST(request: Request) {
|
|
92
|
+
const user = await requireSignedInUser(request); // your auth: return 401 when signed out
|
|
93
|
+
const { token } = await villageApp.tokens.create({ id: user.id, email: user.email });
|
|
94
|
+
return Response.json({ token });
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The browser asks your route for it. Create the client per signed-in user:
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
'use client';
|
|
102
|
+
|
|
103
|
+
import { createVillage } from '@villagehq/sdk';
|
|
104
|
+
import { defineVillageElements } from '@villagehq/sdk/elements';
|
|
105
|
+
import type {} from '@villagehq/sdk/react';
|
|
106
|
+
import { useState } from 'react';
|
|
107
|
+
|
|
108
|
+
async function readVillageToken() {
|
|
109
|
+
const response = await fetch('/api/village/token', { method: 'POST' });
|
|
110
|
+
if (!response.ok) throw new Error(`Village token route answered ${response.status}`);
|
|
111
|
+
const { token } = await response.json();
|
|
112
|
+
return token;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function createUserVillage() {
|
|
116
|
+
const village = createVillage({
|
|
117
|
+
publishableKey: process.env.NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY,
|
|
118
|
+
token: readVillageToken,
|
|
119
|
+
apiUrl: process.env.NEXT_PUBLIC_VILLAGE_API_URL,
|
|
120
|
+
});
|
|
121
|
+
defineVillageElements(village);
|
|
122
|
+
return village;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Render with key={user.id}, so another user gets another client.
|
|
126
|
+
export function WarmIntros({ company }: { company: string }) {
|
|
127
|
+
useState(createUserVillage); // one client for this user, created once
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
<village-connect />
|
|
131
|
+
<village-facepile company={company} />
|
|
132
|
+
</>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
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.
|
|
138
|
+
|
|
139
|
+
### Plain HTML, no build step
|
|
140
|
+
|
|
141
|
+
```html
|
|
142
|
+
<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';
|
|
145
|
+
|
|
146
|
+
const village = createVillage({ publishableKey: 'pk_test_…' }); // a publishable key is public
|
|
147
|
+
defineVillageElements(village);
|
|
148
|
+
</script>
|
|
149
|
+
|
|
150
|
+
<village-connect></village-connect>
|
|
151
|
+
<village-facepile company="stripe.com"><span slot="empty">No warm intros yet</span></village-facepile>
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Pin the version in the URL.
|
|
155
|
+
|
|
156
|
+
### Server only (payer `app`)
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { createVillageApp } from '@villagehq/sdk/server';
|
|
160
|
+
|
|
161
|
+
const villageApp = createVillageApp({ secretKey: process.env.VILLAGE_SECRET_KEY ?? '' });
|
|
162
|
+
const member = villageApp.as({ id: user.id, email: user.email });
|
|
163
|
+
|
|
164
|
+
const { status } = await member.user.get();
|
|
165
|
+
if (status === 'ready' || status === 'needs_attention') {
|
|
166
|
+
const page = await member.companies.paths('stripe.com', { degrees: [1, 2], limit: 10 });
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
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.
|
|
171
|
+
|
|
172
|
+
## 4. User states
|
|
173
|
+
|
|
174
|
+
`village.user.get()` or `village.user.subscribe(({ user, error }) => …)`; `user.status` is one of:
|
|
175
|
+
|
|
176
|
+
| `status` | Meaning | Render |
|
|
177
|
+
| ----------------- | ---------------------------------------------------- | --------------------------------------------------- |
|
|
178
|
+
| `signed_out` | Payer `user`: nobody signed in, or the session ended | A button that calls `village.connect()` |
|
|
179
|
+
| `not_connected` | Payer `app`: no Village network linked yet | A button that calls `village.connect()` |
|
|
180
|
+
| `syncing` | Importing the network; paths come soon | Progress |
|
|
181
|
+
| `ready` | Everything works | The feature |
|
|
182
|
+
| `needs_attention` | Works on older data; an integration needs re-auth | The feature, plus "Reconnect" → `village.connect()` |
|
|
183
|
+
|
|
184
|
+
`isNetworkReady(user)` is `true` for `ready` and `needs_attention`. Call `connect()` from a click: browsers block popups otherwise.
|
|
185
|
+
|
|
186
|
+
## 5. Elements
|
|
187
|
+
|
|
188
|
+
Register once with `defineVillageElements(village)` (from `@villagehq/sdk/elements`).
|
|
189
|
+
|
|
190
|
+
| Element | Attributes | Shows |
|
|
191
|
+
| -------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
192
|
+
| `<village-connect>` | none | The connect button, sync progress, or its default slot when ready (fallback text "Village connected") |
|
|
193
|
+
| `<village-facepile>` | `company` (domain, URL or LinkedIn company URL) | Avatars of people who can introduce you, `+N`, and the strength label |
|
|
194
|
+
| `<village-paths>` | `company` or `person` (LinkedIn URL or slug), `limit` (default 5) | A list of introducers; each is a button |
|
|
195
|
+
|
|
196
|
+
Every element sets `state` and `data-state` to its current state: `loading`, `signed_out`, `not_connected`, `not_synced`, `out_of_credits`, `none`, `found`, `error` (`<village-connect>` uses the user statuses). Its root is an `aria-live` region; images carry the person's name as `alt`.
|
|
197
|
+
|
|
198
|
+
**Customize**, in increasing control:
|
|
199
|
+
|
|
200
|
+
```css
|
|
201
|
+
village-facepile {
|
|
202
|
+
--village-avatar-size: 32px;
|
|
203
|
+
--village-accent: #0f766e; /* buttons, initials, focus ring */
|
|
204
|
+
--village-radius: 6px; /* buttons and list items */
|
|
205
|
+
--village-font: inherit; /* the default */
|
|
206
|
+
}
|
|
207
|
+
village-facepile::part(avatar) {
|
|
208
|
+
border-radius: 6px;
|
|
209
|
+
}
|
|
210
|
+
village-paths::part(item):hover {
|
|
211
|
+
background: #f4f4f5;
|
|
212
|
+
}
|
|
213
|
+
village-facepile[state='none'] {
|
|
214
|
+
display: none;
|
|
215
|
+
}
|
|
216
|
+
village-facepile:not(:defined) {
|
|
217
|
+
display: inline-block;
|
|
218
|
+
min-height: 32px;
|
|
219
|
+
} /* no layout shift */
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Parts: `root`, `avatar`, `more`, `label`, `button`, `item`, `name`, `reason`.
|
|
223
|
+
|
|
224
|
+
Slots (replace the default content):
|
|
225
|
+
|
|
226
|
+
| Slot | When |
|
|
227
|
+
| ------------ | ------------------------------------------------------------ |
|
|
228
|
+
| `loading` | `loading` |
|
|
229
|
+
| `signed-out` | `signed_out` and `not_connected` (default: a connect button) |
|
|
230
|
+
| `action` | The connect button's label |
|
|
231
|
+
| `empty` | `none` |
|
|
232
|
+
|
|
233
|
+
Events (bubbling, composed `CustomEvent`s):
|
|
234
|
+
|
|
235
|
+
| Event | `detail` |
|
|
236
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
237
|
+
| `village-path-select` | `{ person, introducer, degree, reason }`; `introducer` is `null` for someone the user knows directly. Each person is `{ name, initials, avatar_url, linkedin_url }` |
|
|
238
|
+
| `village-connected` | `{ user }`, after `connect()` leaves the user connected (not when they close the popup or modal without connecting) |
|
|
239
|
+
| `village-error` | `{ error }`, when `connect()` fails, e.g. `popup_blocked` |
|
|
240
|
+
|
|
241
|
+
**React 19 + Tailwind.** `import type {} from '@villagehq/sdk/react'` adds the JSX types. Events are `on` + the event name:
|
|
242
|
+
|
|
243
|
+
```tsx
|
|
244
|
+
<village-facepile
|
|
245
|
+
company="stripe.com"
|
|
246
|
+
className="[--village-accent:var(--color-teal-700)] [&::part(avatar)]:rounded-md"
|
|
247
|
+
onvillage-error={(event) => console.error(event.detail.error)}
|
|
248
|
+
>
|
|
249
|
+
<span slot="empty" className="text-sm text-muted-foreground">
|
|
250
|
+
No warm intros yet
|
|
251
|
+
</span>
|
|
252
|
+
</village-facepile>
|
|
253
|
+
```
|
|
254
|
+
|
|
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.
|
|
256
|
+
|
|
257
|
+
## 6. Stores, for your own UI
|
|
258
|
+
|
|
259
|
+
Every element is built on a store. Use them for fully custom UI in any framework.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
const intros = village.paths.check('stripe.com'); // facepile data
|
|
263
|
+
const paths = village.paths.company('stripe.com', { limit: 10 });
|
|
264
|
+
const personPaths = village.paths.person('linkedin.com/in/satyanadella');
|
|
265
|
+
|
|
266
|
+
const stop = intros.subscribe((state) => render(state)); // called right away, then on every change
|
|
267
|
+
intros.get(); // the current state, same object until it changes
|
|
268
|
+
intros.refresh(); // fetch again
|
|
269
|
+
stop();
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
type PathsCheckState =
|
|
274
|
+
| { kind: 'loading' }
|
|
275
|
+
| { kind: 'signed_out' }
|
|
276
|
+
| { kind: 'not_connected' }
|
|
277
|
+
| { kind: 'not_synced' }
|
|
278
|
+
| { kind: 'out_of_credits'; payer: 'app' | 'user'; topupUrl: string | null }
|
|
279
|
+
| { kind: 'none' }
|
|
280
|
+
| { kind: 'found'; people: FacepilePerson[]; more: number; strength: number | null; strength_label: string | null }
|
|
281
|
+
| { kind: 'error'; error: VillageError };
|
|
282
|
+
// paths.person / paths.company: the same, with { kind: 'found'; paths } instead.
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
What the stores do for you:
|
|
286
|
+
|
|
287
|
+
- **Shared**: the same company returns the same store, so 50 badges make one fetch.
|
|
288
|
+
- **Batched**: every `check` asked for in the same tick goes out as one request (100 companies each).
|
|
289
|
+
- **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.
|
|
291
|
+
|
|
292
|
+
React:
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
import { useVillage } from '@villagehq/sdk/react';
|
|
296
|
+
|
|
297
|
+
function IntroBadge({ company }: { company: string }) {
|
|
298
|
+
const state = useVillage(village.paths.check(company));
|
|
299
|
+
if (state.kind === 'found') return <span>{state.people.length + state.more} warm intros</span>;
|
|
300
|
+
if (state.kind === 'signed_out' || state.kind === 'not_connected') return <ConnectButton />;
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Also `useVillageUser(village)` → `{ user, status, isLoading, error, refresh }`, `useVillageConnect(village)` → `{ connect, isConnecting }`, and `<VillageGate village fallback>`.
|
|
306
|
+
|
|
307
|
+
Vue:
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
import { onScopeDispose, shallowRef } from 'vue';
|
|
311
|
+
import type { VillageStore } from '@villagehq/sdk';
|
|
312
|
+
|
|
313
|
+
export function useVillageStore<State>(store: VillageStore<State>) {
|
|
314
|
+
const state = shallowRef(store.get());
|
|
315
|
+
onScopeDispose(store.subscribe((next) => (state.value = next)));
|
|
316
|
+
return state;
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Svelte (stores follow the Svelte store contract):
|
|
321
|
+
|
|
322
|
+
```svelte
|
|
323
|
+
<script>
|
|
324
|
+
const intros = village.paths.check('stripe.com');
|
|
325
|
+
</script>
|
|
326
|
+
{#if $intros.kind === 'found'}{$intros.strength_label}{/if}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Vanilla:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
village.paths.check('stripe.com').subscribe((state) => {
|
|
333
|
+
badge.dataset.state = state.kind;
|
|
334
|
+
badge.textContent = state.kind === 'found' ? `${state.people.length + state.more} warm intros` : '';
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## 7. Other calls
|
|
339
|
+
|
|
340
|
+
Each returns once, and throws a `VillageError`:
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
await village.people.search('CTOs in Berlin'); // a Page: results, hasMore, next(), for await
|
|
344
|
+
await village.companies.search({ prompt: 'fintech in NYC', scope: 'global' });
|
|
345
|
+
await village.people.paths('satyanadella'); // null when Village doesn't know them
|
|
346
|
+
await village.companies.paths('stripe.com', { degrees: [2, 3] });
|
|
347
|
+
await village.people.enrich('satyanadella');
|
|
348
|
+
await village.companies.enrich('stripe.com');
|
|
349
|
+
const { data } = await village.api.GET('/v2/lists'); // any v2 endpoint, typed
|
|
350
|
+
```
|
|
351
|
+
|
|
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
|
|
355
|
+
|
|
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
|
+
```
|
|
382
|
+
|
|
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
|
|
425
|
+
|
|
426
|
+
```
|
|
427
|
+
connect-src https://api.village.ai;
|
|
428
|
+
img-src https:; # avatars come from several CDNs
|
|
429
|
+
frame-src https://village.ai; # payer app: the sync modal
|
|
430
|
+
script-src https://esm.sh; # only for the CDN import
|
|
431
|
+
```
|
|
432
|
+
|
|
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`.
|