@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 +163 -130
- package/README.md +55 -19
- package/bin/village-sdk.mjs +3 -1
- package/dist/core/paths-stores.d.ts +3 -18
- package/dist/core/paths-stores.js +11 -2
- package/dist/core/village.d.ts +1 -25
- package/dist/core/village.js +57 -2
- package/dist/elements/elements.js +8 -0
- package/dist/react/react.d.ts +8 -1
- package/dist/react/react.js +16 -1
- package/dist/server/server.d.ts +1 -25
- package/package.json +2 -10
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
|
|
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.
|
|
8
|
+
## 1. Quick start
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Build and verify locally first. No Village account, human approval, card, or real network is needed until Going live.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
35
|
+
For payer `app`, use:
|
|
27
36
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
+
## 3. Who pays
|
|
35
45
|
|
|
36
|
-
|
|
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
|
-
|
|
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/
|
|
63
|
-
import '
|
|
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) =>
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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.
|
|
144
|
-
import { defineVillageElements } from 'https://esm.sh/@villagehq/sdk@0.2.0-beta.
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
+
### Elements
|
|
187
289
|
|
|
188
|
-
Register
|
|
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
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
440
|
+
### Other calls
|
|
339
441
|
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
+
Start at https://docs.village.ai/build.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
15
|
+
```dotenv
|
|
16
|
+
NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY=pk_test_village_demo_user
|
|
18
17
|
```
|
|
19
18
|
|
|
20
|
-
```
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/bin/village-sdk.mjs
CHANGED
|
@@ -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, () =>
|
|
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))
|
package/dist/core/village.d.ts
CHANGED
|
@@ -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"];
|
package/dist/core/village.js
CHANGED
|
@@ -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:
|
|
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),
|
|
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
|
}
|
package/dist/react/react.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/react/react.js
CHANGED
|
@@ -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);
|
package/dist/server/server.d.ts
CHANGED
|
@@ -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.
|
|
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",
|