howone 0.1.23 → 0.1.26
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/package.json +1 -1
- package/templates/vite/.howone/skills/howone/01-architect/01-app-generation.md +215 -0
- package/templates/vite/.howone/skills/{howone-sdk → howone}/01-architect/02-manifest-codegen.md +67 -4
- package/templates/vite/.howone/skills/howone/02-database/01-schema-design.md +541 -0
- package/templates/vite/.howone/skills/howone/02-database/02-schema-operations.md +398 -0
- package/templates/vite/.howone/skills/howone/02-database/03-data-access-patterns.md +309 -0
- package/templates/vite/.howone/skills/howone/02-database/04-query-dsl-and-responses.md +237 -0
- package/templates/vite/.howone/skills/howone/02-database/05-ai-persistence-patterns.md +372 -0
- package/templates/vite/.howone/skills/{howone-sdk → howone}/03-sdk/01-client-setup.md +58 -36
- package/templates/vite/.howone/skills/{howone-sdk → howone}/03-sdk/02-entity-operations.md +67 -0
- package/templates/vite/.howone/skills/howone/03-sdk/03-auth.md +414 -0
- package/templates/vite/.howone/skills/howone/03-sdk/04-react-integration.md +191 -0
- package/templates/vite/.howone/skills/{howone-sdk → howone}/03-sdk/07-ai-action-calls.md +168 -64
- package/templates/vite/.howone/skills/howone/03-sdk/08-extension-boundaries.md +226 -0
- package/templates/vite/.howone/skills/howone/04-ai/01-ai-capability-architecture.md +205 -0
- package/templates/vite/.howone/skills/howone/04-ai/02-workflow-contract-rules.md +426 -0
- package/templates/vite/.howone/skills/howone/04-ai/03-ai-sdk-handoff.md +234 -0
- package/templates/vite/.howone/skills/howone/04-ai/04-service-capability-catalog.md +281 -0
- package/templates/vite/.howone/skills/howone/04-ai/05-workflow-operations.md +256 -0
- package/templates/vite/.howone/skills/howone/04-ai/06-ai-feature-playbooks.md +296 -0
- package/templates/vite/.howone/skills/{howone-sdk → howone}/SKILL.md +29 -12
- package/templates/vite/.howone/skills/howone/agents/openai.yaml +4 -0
- package/templates/vite/package.json +1 -1
- package/templates/vite/.howone/skills/howone-sdk/01-architect/01-app-generation.md +0 -126
- package/templates/vite/.howone/skills/howone-sdk/02-database/01-schema-design.md +0 -147
- package/templates/vite/.howone/skills/howone-sdk/02-database/02-schema-operations.md +0 -96
- package/templates/vite/.howone/skills/howone-sdk/02-database/03-data-access-patterns.md +0 -172
- package/templates/vite/.howone/skills/howone-sdk/03-sdk/03-auth.md +0 -616
- package/templates/vite/.howone/skills/howone-sdk/03-sdk/04-react-integration.md +0 -398
- package/templates/vite/.howone/skills/howone-sdk/04-ai/.gitkeep +0 -1
- package/templates/vite/.howone/skills/howone-sdk/04-ai/01-ai-capability-architecture.md +0 -142
- package/templates/vite/.howone/skills/howone-sdk/04-ai/02-workflow-contract-rules.md +0 -169
- package/templates/vite/.howone/skills/howone-sdk/04-ai/03-ai-sdk-handoff.md +0 -80
- package/templates/vite/.howone/skills/howone-sdk/agents/openai.yaml +0 -4
- /package/templates/vite/.howone/skills/{howone-sdk → howone}/03-sdk/05-file-upload.md +0 -0
- /package/templates/vite/.howone/skills/{howone-sdk → howone}/03-sdk/06-raw-http.md +0 -0
|
@@ -22,19 +22,18 @@ type CreateClientOptions = {
|
|
|
22
22
|
caseStyle?: 'camel' | 'snake' // Default: 'camel'
|
|
23
23
|
mode?: 'auto' | 'standalone' | 'embedded'
|
|
24
24
|
|
|
25
|
-
// ── Auth
|
|
26
|
-
auth?: {
|
|
27
|
-
mode?: '
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
clearAllUrlParams?: boolean
|
|
35
|
-
sensitiveParams?: string[]
|
|
36
|
-
}
|
|
25
|
+
// ── Auth (one parameter for custom login) ─────────────────
|
|
26
|
+
auth?: 'custom' | 'hosted' | 'headless' | 'none' | {
|
|
27
|
+
mode?: 'custom' | 'hosted' | 'headless' | 'none' | 'managed'
|
|
28
|
+
loginPath?: string // default '/login' when mode is 'custom'
|
|
29
|
+
logoutPath?: string
|
|
30
|
+
guard?: 'required' | 'optional' | 'none'
|
|
31
|
+
getToken?: () => Promise<string | null>
|
|
32
|
+
adapter?: AuthAdapter
|
|
33
|
+
tokenCacheMs?: number
|
|
37
34
|
}
|
|
35
|
+
loginPath?: string // shorthand when auth is 'custom'
|
|
36
|
+
logoutPath?: string
|
|
38
37
|
|
|
39
38
|
// ── Limit-exceeded callbacks ───────────────────────────────
|
|
40
39
|
limitExceeded?: {
|
|
@@ -99,12 +98,16 @@ client.me(options?) // Promise<UserProfile | null>
|
|
|
99
98
|
client.requireMe(options?) // Promise<UserProfile> throws if unauthenticated
|
|
100
99
|
client.session.user() // alias for client.me()
|
|
101
100
|
|
|
102
|
-
// Auth helpers
|
|
101
|
+
// Auth helpers (behavior driven by createClient auth mode)
|
|
102
|
+
client.auth.mode // 'custom' | 'hosted' | 'headless' | 'none'
|
|
103
|
+
client.auth.loginPath // e.g. '/login'
|
|
103
104
|
client.auth.setToken(token: string | null)
|
|
104
105
|
client.auth.getToken(): string | null
|
|
105
106
|
client.auth.isAuthenticated(): boolean
|
|
106
|
-
client.auth.login(
|
|
107
|
-
client.auth.logout()
|
|
107
|
+
client.auth.login(returnUrl?: string)
|
|
108
|
+
await client.auth.logout()
|
|
109
|
+
await client.auth.clearSession({ redirect?: false | string })
|
|
110
|
+
client.auth.subscribe((state) => { ... }) // auth state callback
|
|
108
111
|
|
|
109
112
|
// URL utilities
|
|
110
113
|
client.sanitizeUrl(opts?: { clearAll?: boolean; sensitiveParams?: string[] })
|
|
@@ -121,6 +124,8 @@ import {
|
|
|
121
124
|
defineAiAction,
|
|
122
125
|
defineAiActions,
|
|
123
126
|
defineEntities,
|
|
127
|
+
pickEntityPayload,
|
|
128
|
+
runAiActionAndPersist,
|
|
124
129
|
type EntityRecord,
|
|
125
130
|
withAiActions,
|
|
126
131
|
withEntities,
|
|
@@ -159,6 +164,17 @@ const howone = withAiActions(withEntities(client, entities), ai)
|
|
|
159
164
|
export default howone
|
|
160
165
|
```
|
|
161
166
|
|
|
167
|
+
SDK utility exports that generated apps may use:
|
|
168
|
+
|
|
169
|
+
| Utility | Use |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `pickEntityPayload(definition, payload)` | Keep only schema-declared business fields before create/update. |
|
|
172
|
+
| `validateEntityPayload(definition, payload)` | Return structured issues for unknown/system/ownership/missing required fields. |
|
|
173
|
+
| `assertEntityPayload(definition, payload)` | Throw structured `EntityPayloadValidationError` before unsafe writes. |
|
|
174
|
+
| `validatePublicEntityQuery(definition, options)` | Check public filters, sorts, scopes, and limits against `access.public`. |
|
|
175
|
+
| `assertPublicEntityQuery(definition, options)` | Throw before generating an invalid public query. |
|
|
176
|
+
| `runAiActionAndPersist(options)` | Standard pending-first AI execution + entity persistence helper. |
|
|
177
|
+
|
|
162
178
|
---
|
|
163
179
|
|
|
164
180
|
## Environment Variables
|
|
@@ -173,40 +189,46 @@ VITE_HOWONE_ENV=prod
|
|
|
173
189
|
Rules:
|
|
174
190
|
- **Do not** add `?? 'prod'` or `?? ''` fallbacks. Missing env vars should surface as misconfiguration errors.
|
|
175
191
|
- **Do not** hardcode project IDs in source. Use the env var.
|
|
176
|
-
- `env` accepts `'local'`, `'dev'`, or `'prod'`.
|
|
192
|
+
- `env` accepts `'local'`, `'dev'`, or `'prod'`. **Auth OTP/OAuth, entities, AI, and uploads all use this same env.**
|
|
193
|
+
- Import `src/lib/sdk.ts` before calling `loginWithEmailCode` / `unifiedAuth` so env is pinned (otherwise auth defaults to prod APIs).
|
|
194
|
+
|
|
195
|
+
| `env` | API base | Auth API example |
|
|
196
|
+
|-------|----------|------------------|
|
|
197
|
+
| `local` | `http://localhost:3002/api` | `http://localhost:3002/api/auth/email/send-code` |
|
|
198
|
+
| `dev` | `https://api.howone.dev/api` | `https://api.howone.dev/api/auth/email/send-code` |
|
|
199
|
+
| `prod` | `https://api.howone.ai/api` | `https://api.howone.ai/api/auth/email/send-code` |
|
|
177
200
|
|
|
178
201
|
---
|
|
179
202
|
|
|
180
203
|
## Auth Modes
|
|
181
204
|
|
|
205
|
+
See `03-sdk/03-auth.md` for the full custom-login playbook.
|
|
206
|
+
|
|
182
207
|
```ts
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
projectId: import.meta.env.VITE_HOWONE_PROJECT_ID,
|
|
186
|
-
env: import.meta.env.VITE_HOWONE_ENV,
|
|
187
|
-
auth: { mode: 'managed' },
|
|
188
|
-
})
|
|
208
|
+
// Default — HowOne hosted login (howone.dev / howone.ai)
|
|
209
|
+
createClient({ projectId, env })
|
|
189
210
|
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
211
|
+
// Custom in-app login page; auth APIs still HowOne
|
|
212
|
+
createClient({ projectId, env, auth: 'custom', loginPath: '/login' })
|
|
213
|
+
|
|
214
|
+
// Headless — external JWT provider
|
|
215
|
+
createClient({
|
|
216
|
+
projectId,
|
|
217
|
+
env,
|
|
194
218
|
auth: {
|
|
195
219
|
mode: 'headless',
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
220
|
+
adapter: {
|
|
221
|
+
getToken: async () => externalAuth.getToken(),
|
|
222
|
+
setToken: (token) => externalAuth.setToken(token),
|
|
223
|
+
login: ({ returnUrl } = {}) => router.push(`/login?redirect=${encodeURIComponent(returnUrl ?? '/')}`),
|
|
224
|
+
logout: () => router.push('/'),
|
|
199
225
|
},
|
|
200
|
-
tokenCacheMs: 60_000,
|
|
226
|
+
tokenCacheMs: 60_000,
|
|
201
227
|
},
|
|
202
228
|
})
|
|
203
229
|
|
|
204
|
-
// None —
|
|
205
|
-
|
|
206
|
-
projectId: import.meta.env.VITE_HOWONE_PROJECT_ID,
|
|
207
|
-
env: import.meta.env.VITE_HOWONE_ENV,
|
|
208
|
-
auth: { mode: 'none' },
|
|
209
|
-
})
|
|
230
|
+
// None — public app, no auth
|
|
231
|
+
createClient({ projectId, env, auth: 'none' })
|
|
210
232
|
```
|
|
211
233
|
|
|
212
234
|
---
|
|
@@ -260,7 +260,9 @@ const result = await howone.public.entities.Story.query({
|
|
|
260
260
|
```ts
|
|
261
261
|
type FieldOperator<T> = {
|
|
262
262
|
eq?: T // exact match (same as plain value)
|
|
263
|
+
equals?: T // alias for eq
|
|
263
264
|
ne?: T // not equal
|
|
265
|
+
not?: T // not equal alias
|
|
264
266
|
gt?: T // greater than
|
|
265
267
|
gte?: T // greater than or equal
|
|
266
268
|
lt?: T // less than
|
|
@@ -268,9 +270,14 @@ type FieldOperator<T> = {
|
|
|
268
270
|
contains?: string // substring (string fields)
|
|
269
271
|
like?: string // SQL LIKE pattern
|
|
270
272
|
startsWith?: string
|
|
273
|
+
starts?: string
|
|
271
274
|
endsWith?: string
|
|
275
|
+
ends?: string
|
|
272
276
|
in?: T[] // value in array
|
|
273
277
|
notIn?: T[] // value not in array
|
|
278
|
+
null?: boolean // null / not null
|
|
279
|
+
empty?: boolean // empty / not empty
|
|
280
|
+
exists?: boolean // field exists / missing
|
|
274
281
|
}
|
|
275
282
|
|
|
276
283
|
// Examples
|
|
@@ -346,6 +353,26 @@ Public query fields must be present in `access.public.allowedFilters`, and publi
|
|
|
346
353
|
fields must be present in `access.public.allowedSorts`. If a public query is rejected,
|
|
347
354
|
fix the schema access contract instead of falling back to authenticated APIs.
|
|
348
355
|
|
|
356
|
+
When generated code has the synced entity definition available, validate public queries before
|
|
357
|
+
calling the API:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
import { assertPublicEntityQuery } from '@howone/sdk'
|
|
361
|
+
import { articleEntityDefinition } from '@/lib/sdk'
|
|
362
|
+
|
|
363
|
+
const query = {
|
|
364
|
+
where: { status: 'published' },
|
|
365
|
+
orderBy: { publishedAt: 'desc' },
|
|
366
|
+
page: { number: 1, size: 20 },
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
assertPublicEntityQuery(articleEntityDefinition, query)
|
|
370
|
+
const result = await howone.public.entities.Article.query(query)
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Use `validatePublicEntityQuery()` when the app wants to show its own validation UI instead of
|
|
374
|
+
throwing.
|
|
375
|
+
|
|
349
376
|
---
|
|
350
377
|
|
|
351
378
|
## Public Writes
|
|
@@ -365,6 +392,44 @@ await howone.public.entities.ContactMessage.create({
|
|
|
365
392
|
|
|
366
393
|
---
|
|
367
394
|
|
|
395
|
+
## Payload Contract Utilities
|
|
396
|
+
|
|
397
|
+
Use these helpers when code maps form state, AI output, or mixed UI state into entity writes.
|
|
398
|
+
They prevent the common mistake of sending UI-only, workflow-envelope, ownership, or system fields.
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
import { pickEntityPayload, assertEntityPayload } from '@howone/sdk'
|
|
402
|
+
import { generationEntityDefinition } from '@/lib/sdk'
|
|
403
|
+
|
|
404
|
+
const draft = {
|
|
405
|
+
prompt,
|
|
406
|
+
status: 'pending',
|
|
407
|
+
created_by_id: user.id, // stripped/rejected
|
|
408
|
+
gradientDirection: 'to right', // stripped/rejected unless schema declares it
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const payload = pickEntityPayload(generationEntityDefinition, draft)
|
|
412
|
+
assertEntityPayload(generationEntityDefinition, payload)
|
|
413
|
+
|
|
414
|
+
await howone.entities.Generation.create(payload)
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Rules:
|
|
418
|
+
|
|
419
|
+
- Use `pickEntityPayload()` when transforming broad UI objects into narrow create/update payloads.
|
|
420
|
+
- Use `validateEntityPayload()` to collect issues for app-owned validation UI.
|
|
421
|
+
- Use `assertEntityPayload()` before writes in generated helper functions.
|
|
422
|
+
- For updates, pass `{ partial: true }` to avoid requiring create-time fields.
|
|
423
|
+
- These helpers do not replace backend validation; they make generated frontend code fail earlier
|
|
424
|
+
with clearer errors.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
assertEntityPayload(generationEntityDefinition, update, { partial: true })
|
|
428
|
+
await howone.entities.Generation.update(id, update)
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
368
433
|
## Bulk Create
|
|
369
434
|
|
|
370
435
|
```ts
|
|
@@ -474,3 +539,5 @@ function useDeleteStory() {
|
|
|
474
539
|
| `client.entity('Story')` without generics | `client.entity<StoryRecord, StoryCreate, StoryUpdate>('Story')` |
|
|
475
540
|
| Using `list()` when you need pagination | Use `query()` for paginated UIs |
|
|
476
541
|
| Calling `query()` inside render without guarding re-runs | Wrap in `useEffect` with cancellation or use TanStack Query |
|
|
542
|
+
| Sending form/workflow object directly to `create()` | Use `pickEntityPayload()` and `assertEntityPayload()` |
|
|
543
|
+
| Public query with illegal field/sort | Use `assertPublicEntityQuery()` and fix schema guardrails |
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# Auth
|
|
2
|
+
|
|
3
|
+
## Environment (read this first — dev must not hit prod)
|
|
4
|
+
|
|
5
|
+
All auth APIs (`sendEmailVerificationCode`, `loginWithEmailCode`, `unifiedAuth.*`, `howone.auth.logout` revoke) use the **same `env` as `createClient`**, not the browser hostname and not a frozen default.
|
|
6
|
+
|
|
7
|
+
| `VITE_HOWONE_ENV` / `createClient({ env })` | Auth REST API origin | Hosted login root (`auth: 'hosted'`) |
|
|
8
|
+
|---------------------------------------------|----------------------|--------------------------------------|
|
|
9
|
+
| `local` | `http://localhost:3002` | `https://howone.dev` |
|
|
10
|
+
| `dev` | `https://api.howone.dev` | `https://howone.dev` |
|
|
11
|
+
| `prod` | `https://api.howone.ai` | `https://howone.ai` |
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// src/lib/sdk.ts — canonical (no extra auth fields required)
|
|
15
|
+
const client = createClient({
|
|
16
|
+
projectId: import.meta.env.VITE_HOWONE_PROJECT_ID,
|
|
17
|
+
env: import.meta.env.VITE_HOWONE_ENV,
|
|
18
|
+
})
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Defaults when `auth` is omitted:
|
|
22
|
+
|
|
23
|
+
- Auth mode: **hosted** — login/logout redirect to HowOne (`howone.dev` / `howone.ai` by `env`)
|
|
24
|
+
- Auth APIs still follow `env` (`dev` → `api.howone.dev`, `prod` → `api.howone.ai`)
|
|
25
|
+
|
|
26
|
+
Custom in-app login UI is **opt-in**: `auth: 'custom'` (see below).
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// main.tsx
|
|
30
|
+
import './lib/sdk' // registers env first
|
|
31
|
+
import { sendEmailVerificationCode } from '@howone/sdk'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Rules for agents:
|
|
35
|
+
|
|
36
|
+
- **Do not** hardcode `api.howone.ai` or `howone.ai` in app code.
|
|
37
|
+
- **Do not** import auth helpers before `./lib/sdk` (env would stay default `prod`).
|
|
38
|
+
- **Do not** rely on `localhost` hostname to pick `local`; use `env: 'local'` or `env: 'dev'` explicitly.
|
|
39
|
+
- Entity, AI, upload, and auth endpoints all follow this single `env` pin.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Custom login (opt-in only)
|
|
44
|
+
|
|
45
|
+
Add `auth: 'custom'` when the app renders its own `/login` page but still uses HowOne OTP/OAuth APIs:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const client = createClient({
|
|
49
|
+
projectId: import.meta.env.VITE_HOWONE_PROJECT_ID,
|
|
50
|
+
env: import.meta.env.VITE_HOWONE_ENV,
|
|
51
|
+
auth: 'custom',
|
|
52
|
+
loginPath: '/login',
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
That wires:
|
|
57
|
+
|
|
58
|
+
| Behavior | Result |
|
|
59
|
+
|----------|--------|
|
|
60
|
+
| `howone.auth.logout()` | Clears token + revokes server session → stays on your app → navigates to `loginPath` |
|
|
61
|
+
| `howone.auth.login()` | Navigates to `loginPath` (never howone.dev / howone.ai) |
|
|
62
|
+
| `HowOneProvider` `useHowoneContext().logout()` | Same as `howone.auth.logout()` when `createClient` ran first |
|
|
63
|
+
|
|
64
|
+
Pair with React:
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
<HowOneProvider auth="none" brand="visible">
|
|
68
|
+
<App />
|
|
69
|
+
</HowOneProvider>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`auth="none"` on the provider means **no automatic redirect**; route guards call `howone.me()` and `navigate('/login')` yourself. Keep `brand="visible"` unless the user explicitly asks to hide the bottom-right HowOne logo.
|
|
73
|
+
|
|
74
|
+
| `createClient({ auth })` | Login UI | `auth.login()` | `auth.logout()` default redirect |
|
|
75
|
+
|--------------------------|----------|----------------|----------------------------------|
|
|
76
|
+
| *(omit)* | **HowOne hosted** | → howone `/auth` | → howone `/auth` |
|
|
77
|
+
| `'custom'` | Your `/login` + HowOne APIs | → `loginPath` | → `loginPath` |
|
|
78
|
+
| `'hosted'` | HowOne hosted (same as default) | → howone `/auth` | → howone `/auth` |
|
|
79
|
+
| `'headless'` | External (Clerk, etc.) | no-op | no redirect |
|
|
80
|
+
| `'none'` | N/A (public app) | no-op | no redirect |
|
|
81
|
+
|
|
82
|
+
Legacy alias: `'managed'` → `'hosted'`.
|
|
83
|
+
|
|
84
|
+
### Advanced object form
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
auth: {
|
|
88
|
+
mode: 'custom',
|
|
89
|
+
loginPath: '/sign-in',
|
|
90
|
+
logoutPath: '/sign-in', // optional; defaults to loginPath
|
|
91
|
+
guard: 'required', // optional; use with HowOneProvider auth="required" for auto-redirect to loginPath
|
|
92
|
+
getToken: async () => null, // only for headless
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Two auth layers
|
|
99
|
+
|
|
100
|
+
1. **`createClient({ auth })`** — strategy for login/logout destinations (hosted vs custom).
|
|
101
|
+
2. **`unifiedAuth` / standalone OTP & OAuth functions** — headless APIs to build your login UI.
|
|
102
|
+
|
|
103
|
+
React: `HowOneProvider` + `useHowoneContext` — route guard only (`auth="required" | "optional" | "none"`).
|
|
104
|
+
|
|
105
|
+
The underlying SDK auth state is managed by `AuthManager`. App code normally uses `client.auth`,
|
|
106
|
+
`client.me()`, `client.requireMe()`, and `HowOneProvider`; SDK contributors use `AuthAdapter` when
|
|
107
|
+
custom token ownership is required.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## `client.auth` API
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import howone from '@/lib/sdk'
|
|
115
|
+
|
|
116
|
+
howone.auth.mode // 'custom' | 'hosted' | 'headless' | 'none'
|
|
117
|
+
howone.auth.guard // 'required' | 'optional' | 'none'
|
|
118
|
+
howone.auth.loginPath // e.g. '/login'
|
|
119
|
+
howone.auth.logoutPath // e.g. '/login'
|
|
120
|
+
|
|
121
|
+
howone.auth.setToken(jwt)
|
|
122
|
+
howone.auth.getToken()
|
|
123
|
+
howone.auth.isAuthenticated()
|
|
124
|
+
|
|
125
|
+
howone.auth.login(returnUrl?) // respects auth mode
|
|
126
|
+
await howone.auth.logout() // respects auth mode
|
|
127
|
+
await howone.auth.clearSession() // clear only; redirect: false
|
|
128
|
+
await howone.auth.clearSession({ redirect: '/goodbye' })
|
|
129
|
+
await howone.auth.logout({ redirect: false })
|
|
130
|
+
howone.auth.subscribe((state) => {
|
|
131
|
+
// state: { token, user, isAuthenticated, isLoading }
|
|
132
|
+
})
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## AuthManager / AuthAdapter extension point
|
|
138
|
+
|
|
139
|
+
Use an adapter when the token is owned outside the default HowOne local session, for example an
|
|
140
|
+
external auth provider, embedded shell, native app bridge, or custom host application.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const client = createClient({
|
|
144
|
+
projectId,
|
|
145
|
+
env,
|
|
146
|
+
auth: {
|
|
147
|
+
mode: 'headless',
|
|
148
|
+
adapter: {
|
|
149
|
+
name: 'external-auth',
|
|
150
|
+
getToken: () => externalAuth.getToken(),
|
|
151
|
+
setToken: (token) => externalAuth.setToken(token),
|
|
152
|
+
getUser: async (token) => externalAuth.getUser(token),
|
|
153
|
+
login: ({ returnUrl } = {}) => {
|
|
154
|
+
router.push(`/login?redirect=${encodeURIComponent(returnUrl ?? '/')}`)
|
|
155
|
+
},
|
|
156
|
+
logout: () => {
|
|
157
|
+
externalAuth.clear()
|
|
158
|
+
router.push('/')
|
|
159
|
+
},
|
|
160
|
+
subscribe: (listener) => externalAuth.onChange(() => {
|
|
161
|
+
listener({
|
|
162
|
+
token: externalAuth.getToken(),
|
|
163
|
+
user: externalAuth.getUserSync(),
|
|
164
|
+
isAuthenticated: Boolean(externalAuth.getToken()),
|
|
165
|
+
isLoading: false,
|
|
166
|
+
})
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
tokenCacheMs: 30_000,
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Adapter rules:
|
|
175
|
+
|
|
176
|
+
- Use `mode: 'headless'` for external token providers.
|
|
177
|
+
- Use `mode: 'custom'` for in-app login pages that still call HowOne OTP/OAuth APIs.
|
|
178
|
+
- Do not create extra token storage keys in app code. Route all token writes through `client.auth.setToken()`.
|
|
179
|
+
- Do not implement app UI in the adapter. It may navigate or notify through callbacks, but visible UI belongs to the app.
|
|
180
|
+
- `getToken` may be sync or async; the SDK caches external tokens according to `tokenCacheMs`.
|
|
181
|
+
- `subscribe` is optional but recommended when the external auth provider can change outside SDK calls.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Custom login page (full pattern for AI codegen)
|
|
186
|
+
|
|
187
|
+
### 1. SDK (`src/lib/sdk.ts`)
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { createClient, defineEntities, withEntities } from '@howone/sdk'
|
|
191
|
+
|
|
192
|
+
const client = createClient({
|
|
193
|
+
projectId: import.meta.env.VITE_HOWONE_PROJECT_ID,
|
|
194
|
+
env: import.meta.env.VITE_HOWONE_ENV,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
export default withEntities(client, defineEntities({ /* ... */ }))
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### 2. Provider (`main.tsx`)
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
import { HowOneProvider } from '@howone/sdk/react'
|
|
204
|
+
import './lib/sdk' // registers auth config
|
|
205
|
+
|
|
206
|
+
<HowOneProvider auth="none" brand="visible">
|
|
207
|
+
<App />
|
|
208
|
+
</HowOneProvider>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### 3. Login route (`/login`) — your styles, HowOne APIs
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
import {
|
|
215
|
+
sendEmailVerificationCode,
|
|
216
|
+
loginWithEmailCode,
|
|
217
|
+
unifiedAuth,
|
|
218
|
+
} from '@howone/sdk'
|
|
219
|
+
import howone from '@/lib/sdk'
|
|
220
|
+
|
|
221
|
+
// Email OTP
|
|
222
|
+
const send = await sendEmailVerificationCode(email)
|
|
223
|
+
const result = await loginWithEmailCode(email, code)
|
|
224
|
+
if (result.success && result.token) {
|
|
225
|
+
howone.auth.setToken(result.token)
|
|
226
|
+
const user = await howone.me({ refresh: true })
|
|
227
|
+
navigate('/')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Google (popup — user stays on your page)
|
|
231
|
+
const { token } = await unifiedAuth.initiateGoogleLogin()
|
|
232
|
+
howone.auth.setToken(token)
|
|
233
|
+
|
|
234
|
+
// GitHub
|
|
235
|
+
const { token } = await unifiedAuth.initiateGitHubLogin()
|
|
236
|
+
howone.auth.setToken(token)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Phone OTP: `sendPhoneVerificationCode` + `loginWithPhoneCode` (E.164, e.g. `+8613800138000`).
|
|
240
|
+
|
|
241
|
+
OAuth full-page callback (optional route `/auth/callback`):
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import { unifiedOAuth } from '@howone/sdk'
|
|
245
|
+
|
|
246
|
+
const result = unifiedOAuth.checkOAuthCallback()
|
|
247
|
+
if (result.success && result.token) {
|
|
248
|
+
howone.auth.setToken(result.token)
|
|
249
|
+
navigate('/')
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### 4. Protected routes
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
howone.me()
|
|
258
|
+
.then(setUser)
|
|
259
|
+
.catch(() => navigate('/login', { replace: true }))
|
|
260
|
+
}, [])
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Use `howone.me()`, not `howone.auth.isAuthenticated()`, for first load.
|
|
264
|
+
|
|
265
|
+
### 5. Logout button
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
await howone.auth.logout()
|
|
269
|
+
// custom mode: already navigates to loginPath; no howone.dev redirect
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Do **not** call hosted-only patterns when `auth: 'custom'` is set:
|
|
273
|
+
|
|
274
|
+
- ~~`howone.auth.login()` expecting howone.ai~~ (goes to `/login` instead — OK)
|
|
275
|
+
- ~~Manual `window.location` to howone.dev~~
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Hosted login (default)
|
|
280
|
+
|
|
281
|
+
Omit `auth` — this is the default for `createClient({ projectId, env })`:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
createClient({ projectId, env })
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
```tsx
|
|
288
|
+
<HowOneProvider auth="required">
|
|
289
|
+
<App />
|
|
290
|
+
</HowOneProvider>
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Unauthenticated users redirect to HowOne hosted `/auth`.
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Headless external auth
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
createClient({
|
|
301
|
+
projectId,
|
|
302
|
+
env,
|
|
303
|
+
auth: {
|
|
304
|
+
mode: 'headless',
|
|
305
|
+
adapter: {
|
|
306
|
+
getToken: async () => externalAuth.getJwt(),
|
|
307
|
+
login: ({ returnUrl } = {}) => externalAuth.login({ returnUrl }),
|
|
308
|
+
logout: () => externalAuth.logout(),
|
|
309
|
+
},
|
|
310
|
+
tokenCacheMs: 30_000,
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Do not use `howone.auth.setToken` for Clerk/Supabase unless bridging into HowOne JWT.
|
|
316
|
+
|
|
317
|
+
Headless mode should not redirect to HowOne hosted auth by default. The external adapter decides
|
|
318
|
+
what `login` and `logout` mean.
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Email / phone / OAuth API reference
|
|
323
|
+
|
|
324
|
+
(Same as before — see sections below for request/response shapes.)
|
|
325
|
+
|
|
326
|
+
### Email OTP
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
import { sendEmailVerificationCode, loginWithEmailCode } from '@howone/sdk'
|
|
330
|
+
|
|
331
|
+
await sendEmailVerificationCode(email, appName?)
|
|
332
|
+
const result = await loginWithEmailCode(email, code)
|
|
333
|
+
// result.token on success → howone.auth.setToken(result.token)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### Phone OTP
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { sendPhoneVerificationCode, loginWithPhoneCode } from '@howone/sdk'
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### OAuth popup
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
import { unifiedAuth } from '@howone/sdk'
|
|
346
|
+
|
|
347
|
+
await unifiedAuth.initiateGoogleLogin()
|
|
348
|
+
await unifiedAuth.initiateGitHubLogin()
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Server logout
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
import { unifiedAuth } from '@howone/sdk'
|
|
355
|
+
|
|
356
|
+
const token = howone.auth.getToken()
|
|
357
|
+
if (token) await unifiedAuth.logout(token)
|
|
358
|
+
await howone.auth.logout()
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
With `auth: 'custom'`, `howone.auth.logout()` already revokes and navigates locally.
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## User profile
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
const user = await howone.me()
|
|
369
|
+
const user = await howone.requireMe() // throws HowOneAuthError
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## HowOneAuthError
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
import { HowOneAuthError } from '@howone/sdk'
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
await howone.requireMe()
|
|
381
|
+
} catch (err) {
|
|
382
|
+
if (err instanceof HowOneAuthError) {
|
|
383
|
+
howone.auth.login() // custom → /login; hosted → howone.ai
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## Common mistakes (AI agents)
|
|
391
|
+
|
|
392
|
+
| Mistake | Fix |
|
|
393
|
+
|---------|-----|
|
|
394
|
+
| Custom `/login` page with HowOne OTP/OAuth | Add `auth: 'custom'` in `createClient` |
|
|
395
|
+
| `HowOneProvider auth="required"` + custom login | Use `auth="none"`; guard with `howone.me()` |
|
|
396
|
+
| `howone.auth.logout()` expecting no redirect before this change | Now respects `auth: 'custom'` |
|
|
397
|
+
| `auth.isAuthenticated()` on first paint | Use `await howone.me()` |
|
|
398
|
+
| Phone without country code | E.164 `+86...` |
|
|
399
|
+
| JSON Schema in `defineAiAction` | Convert manifest JSON Schema to Zod |
|
|
400
|
+
| Second localStorage key for token | Only `howone.auth.setToken` |
|
|
401
|
+
| Custom external provider wired with `getToken` only but no logout | Add an `adapter.logout` if the host owns session clearing |
|
|
402
|
+
| App UI inside SDK auth adapter | Move UI to frontend components and use callbacks/navigation only |
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Non-negotiable for agents
|
|
407
|
+
|
|
408
|
+
- Default **`createClient({ projectId, env })`** = HowOne hosted login. Add **`auth: 'custom'`** only for in-app login pages.
|
|
409
|
+
- Never hardcode howone.dev / howone.ai URLs in app login/logout when `auth: 'custom'`.
|
|
410
|
+
- Implement login UI with `sendEmailVerificationCode` / `loginWithEmailCode` / `unifiedAuth` — not iframe to hosted auth.
|
|
411
|
+
- After any successful login: `howone.auth.setToken(token)` then `await howone.me({ refresh: true })`.
|
|
412
|
+
- Logout: `await howone.auth.logout()` only (no manual redirect needed when `auth: 'custom'`).
|
|
413
|
+
- For external auth, use `auth.adapter`; do not patch request headers manually.
|
|
414
|
+
- SDK auth exposes state/callbacks only. Visible feedback, loading spinners, account menus, and errors are frontend app code.
|