create-theokit 0.1.0-alpha.13 → 0.1.0-alpha.14
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/api-only/README.md.tmpl +2 -0
- package/templates/api-only/package.json.tmpl +1 -1
- package/templates/api-only/server/routes/webhooks/echo.ts +34 -0
- package/templates/dashboard/README.md.tmpl +2 -0
- package/templates/dashboard/package.json.tmpl +1 -1
- package/templates/dashboard/server/crons/cleanup-conversations.ts +51 -0
- package/templates/default/README.md.tmpl +2 -0
- package/templates/default/package.json.tmpl +1 -1
- package/templates/default/server/crons/cleanup-conversations.ts +51 -0
- package/templates/postgres/README.md.tmpl +2 -0
- package/templates/postgres/package.json.tmpl +1 -1
- package/templates/postgres/server/jobs/log-message.ts +27 -0
- package/templates/saas/README.md.tmpl +2 -0
- package/templates/saas/package.json.tmpl +1 -1
- package/templates/saas/server/routes/agent.ts +32 -4
- package/templates/saas/server/routes/billing/stripe-webhook.ts +49 -0
package/package.json
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { defineWebhook } from 'theokit/server'
|
|
2
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Echo webhook — demonstrates `defineWebhook` HMAC-SHA256 pattern
|
|
7
|
+
* without depending on an external provider (Stripe, GitHub, etc.).
|
|
8
|
+
*
|
|
9
|
+
* Self-test:
|
|
10
|
+
* SECRET=$(openssl rand -base64 32)
|
|
11
|
+
* echo -n '{"message":"hi"}' | openssl dgst -sha256 -hmac "$SECRET"
|
|
12
|
+
* curl -X POST localhost:3000/api/webhooks/echo \
|
|
13
|
+
* -H "x-echo-signature: <hex from above>" \
|
|
14
|
+
* -H "Content-Type: application/json" \
|
|
15
|
+
* -d '{"message":"hi"}'
|
|
16
|
+
*/
|
|
17
|
+
const ECHO_SECRET = process.env.ECHO_WEBHOOK_SECRET ?? ''
|
|
18
|
+
|
|
19
|
+
export const POST = defineWebhook({
|
|
20
|
+
verify: ({ rawBody, headers }) => {
|
|
21
|
+
if (ECHO_SECRET === '') return false
|
|
22
|
+
const sig = headers.get('x-echo-signature') ?? ''
|
|
23
|
+
const expected = createHmac('sha256', ECHO_SECRET).update(rawBody).digest('hex')
|
|
24
|
+
try {
|
|
25
|
+
return timingSafeEqual(Buffer.from(sig, 'utf-8'), Buffer.from(expected, 'utf-8'))
|
|
26
|
+
} catch {
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
inputSchema: z.object({ message: z.string() }),
|
|
31
|
+
handler: async ({ input }) => {
|
|
32
|
+
return Response.json({ echoed: input.message, timestamp: new Date().toISOString() })
|
|
33
|
+
},
|
|
34
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { defineCron } from 'theokit/server/cron'
|
|
2
|
+
import { readdir, stat, rm } from 'node:fs/promises'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Daily GC of stale conversation transcripts.
|
|
7
|
+
*
|
|
8
|
+
* The `@usetheo/sdk` Agent persists chat history under
|
|
9
|
+
* `.theokit/agents/<agentId>/messages.jsonl`. With no TTL the directory
|
|
10
|
+
* grows unbounded — production foot-gun. This cron removes any agent
|
|
11
|
+
* directory whose `messages.jsonl` hasn't been touched in 30 days.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_AGE_DAYS = 30
|
|
14
|
+
const AGENTS_DIR = '.theokit/agents'
|
|
15
|
+
|
|
16
|
+
export default defineCron({
|
|
17
|
+
name: 'cleanup-conversations',
|
|
18
|
+
schedule: '0 4 * * *', // Daily 04:00 UTC
|
|
19
|
+
handler: async ({ log }) => {
|
|
20
|
+
const root = resolve(process.cwd(), AGENTS_DIR)
|
|
21
|
+
const cutoff = Date.now() - MAX_AGE_DAYS * 24 * 60 * 60 * 1000
|
|
22
|
+
let removed = 0
|
|
23
|
+
let kept = 0
|
|
24
|
+
let entries: Awaited<ReturnType<typeof readdir>>
|
|
25
|
+
try {
|
|
26
|
+
entries = await readdir(root, { withFileTypes: true })
|
|
27
|
+
} catch {
|
|
28
|
+
log.info({ msg: 'No agents dir yet — first run', dir: root })
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
if (!entry.isDirectory()) continue
|
|
33
|
+
const agentDir = join(root, entry.name)
|
|
34
|
+
const messagesFile = join(agentDir, 'messages.jsonl')
|
|
35
|
+
try {
|
|
36
|
+
const s = await stat(messagesFile)
|
|
37
|
+
if (s.mtimeMs < cutoff) {
|
|
38
|
+
await rm(agentDir, { recursive: true, force: true })
|
|
39
|
+
removed++
|
|
40
|
+
} else {
|
|
41
|
+
kept++
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// messages.jsonl missing → orphan dir, remove
|
|
45
|
+
await rm(agentDir, { recursive: true, force: true }).catch(() => {})
|
|
46
|
+
removed++
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
log.info({ msg: 'cleanup-conversations complete', removed, kept, maxAgeDays: MAX_AGE_DAYS })
|
|
50
|
+
},
|
|
51
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { defineCron } from 'theokit/server/cron'
|
|
2
|
+
import { readdir, stat, rm } from 'node:fs/promises'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Daily GC of stale conversation transcripts.
|
|
7
|
+
*
|
|
8
|
+
* The `@usetheo/sdk` Agent persists chat history under
|
|
9
|
+
* `.theokit/agents/<agentId>/messages.jsonl`. With no TTL the directory
|
|
10
|
+
* grows unbounded — production foot-gun. This cron removes any agent
|
|
11
|
+
* directory whose `messages.jsonl` hasn't been touched in 30 days.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_AGE_DAYS = 30
|
|
14
|
+
const AGENTS_DIR = '.theokit/agents'
|
|
15
|
+
|
|
16
|
+
export default defineCron({
|
|
17
|
+
name: 'cleanup-conversations',
|
|
18
|
+
schedule: '0 4 * * *', // Daily 04:00 UTC
|
|
19
|
+
handler: async ({ log }) => {
|
|
20
|
+
const root = resolve(process.cwd(), AGENTS_DIR)
|
|
21
|
+
const cutoff = Date.now() - MAX_AGE_DAYS * 24 * 60 * 60 * 1000
|
|
22
|
+
let removed = 0
|
|
23
|
+
let kept = 0
|
|
24
|
+
let entries: Awaited<ReturnType<typeof readdir>>
|
|
25
|
+
try {
|
|
26
|
+
entries = await readdir(root, { withFileTypes: true })
|
|
27
|
+
} catch {
|
|
28
|
+
log.info({ msg: 'No agents dir yet — first run', dir: root })
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
if (!entry.isDirectory()) continue
|
|
33
|
+
const agentDir = join(root, entry.name)
|
|
34
|
+
const messagesFile = join(agentDir, 'messages.jsonl')
|
|
35
|
+
try {
|
|
36
|
+
const s = await stat(messagesFile)
|
|
37
|
+
if (s.mtimeMs < cutoff) {
|
|
38
|
+
await rm(agentDir, { recursive: true, force: true })
|
|
39
|
+
removed++
|
|
40
|
+
} else {
|
|
41
|
+
kept++
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// messages.jsonl missing → orphan dir, remove
|
|
45
|
+
await rm(agentDir, { recursive: true, force: true }).catch(() => {})
|
|
46
|
+
removed++
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
log.info({ msg: 'cleanup-conversations complete', removed, kept, maxAgeDays: MAX_AGE_DAYS })
|
|
50
|
+
},
|
|
51
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineJob } from 'theokit/server/jobs'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { appendFile, mkdir } from 'node:fs/promises'
|
|
4
|
+
import { resolve, dirname } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Background job demonstrating `defineJob` + `ctx.queue.enqueue` pattern.
|
|
8
|
+
*
|
|
9
|
+
* Triggered from `server/routes/users.ts` POST handler via:
|
|
10
|
+
* await ctx.queue.enqueue('log-message', { userId, message })
|
|
11
|
+
*
|
|
12
|
+
* Per ADR-0003 (transactional outbox), enqueue is deferred until the
|
|
13
|
+
* route handler commits successfully — handler throws → 0 jobs dispatched.
|
|
14
|
+
*/
|
|
15
|
+
export default defineJob({
|
|
16
|
+
name: 'log-message',
|
|
17
|
+
input: z.object({ userId: z.string(), message: z.string() }),
|
|
18
|
+
handler: async ({ input, log }) => {
|
|
19
|
+
// v1.1 EC-9: anchor path to process.cwd() — handler CWD may differ from
|
|
20
|
+
// project root when running via external job runner.
|
|
21
|
+
const auditPath = resolve(process.cwd(), '.theo/audit.log')
|
|
22
|
+
await mkdir(dirname(auditPath), { recursive: true })
|
|
23
|
+
const line = `${new Date().toISOString()} user=${input.userId} msg=${input.message}\n`
|
|
24
|
+
await appendFile(auditPath, line)
|
|
25
|
+
log.info({ msg: 'audit logged', userId: input.userId, path: auditPath })
|
|
26
|
+
},
|
|
27
|
+
})
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { defineAgentEndpoint, requireAuth, type AgentEvent } from 'theokit/server'
|
|
2
|
+
import { trackAgentRun } from 'theokit/server/cost'
|
|
2
3
|
import type { RequestContext } from '../context.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Protected agent endpoint. `requireAuth` fires BEFORE the stream starts;
|
|
6
7
|
* unauthorized requests get 401 immediately — no SSE bytes leak.
|
|
7
8
|
*
|
|
9
|
+
* Observability: wraps the run with `trackAgentRun` to surface per-user
|
|
10
|
+
* cost + token usage to the configured `UsageStorageAdapter` (configure via
|
|
11
|
+
* `theo.config.ts > cost.storage`). Also feeds the devtools `Agents` tab
|
|
12
|
+
* (when running in dev).
|
|
13
|
+
*
|
|
14
|
+
* NOTE: `costUsd: 0` is a v1 stub. Pricing table integration is a
|
|
15
|
+
* `@usetheo/sdk` follow-up (R0.5.11). Devtools tab renders "$0.0000" —
|
|
16
|
+
* indicates "cost tracking not yet calibrated for this model".
|
|
17
|
+
*
|
|
8
18
|
* Replace the mock generator with your LLM provider call.
|
|
9
19
|
*/
|
|
10
20
|
export const POST = defineAgentEndpoint<{ message: string }, RequestContext>({
|
|
@@ -12,10 +22,28 @@ export const POST = defineAgentEndpoint<{ message: string }, RequestContext>({
|
|
|
12
22
|
requireAuth(ctx.session)
|
|
13
23
|
const body = (await request.json()) as { message?: string }
|
|
14
24
|
const msg = body.message ?? ''
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
25
|
+
try {
|
|
26
|
+
yield {
|
|
27
|
+
type: 'message',
|
|
28
|
+
content: `Hello ${ctx.session.email}, you said: "${msg}"`,
|
|
29
|
+
}
|
|
30
|
+
yield { type: 'message', content: '(Replace this mock with your LLM.)' }
|
|
31
|
+
} finally {
|
|
32
|
+
// Always emit observability — even on stream error / abort.
|
|
33
|
+
// `storage` resolved from theo.config.ts > cost.storage (undefined =
|
|
34
|
+
// no-op; configure to enable persistence + devtools tab visibility).
|
|
35
|
+
// To enable persistent cost tracking: wire `cost: { storage }` into
|
|
36
|
+
// `theo.config.ts` and forward via context. Demo passes `undefined`
|
|
37
|
+
// (no-op storage; still fires devtools dispatcher in dev mode).
|
|
38
|
+
await trackAgentRun(
|
|
39
|
+
{
|
|
40
|
+
userId: ctx.session.email,
|
|
41
|
+
model: 'mock/echo',
|
|
42
|
+
tokens: { input: msg.length, output: 0 }, // crude — real impl uses tokenizer
|
|
43
|
+
costUsd: 0, // v1 stub
|
|
44
|
+
},
|
|
45
|
+
{ storage: undefined },
|
|
46
|
+
)
|
|
18
47
|
}
|
|
19
|
-
yield { type: 'message', content: '(Replace this mock with your LLM.)' }
|
|
20
48
|
},
|
|
21
49
|
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { defineWebhook } from 'theokit/server'
|
|
2
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stripe webhook receiver.
|
|
7
|
+
*
|
|
8
|
+
* Verifies `Stripe-Signature` header per Stripe's documented HMAC-SHA256
|
|
9
|
+
* scheme (https://stripe.com/docs/webhooks/signatures). Real impl would
|
|
10
|
+
* handle `checkout.session.completed`, `invoice.paid`, etc.
|
|
11
|
+
*
|
|
12
|
+
* Setup:
|
|
13
|
+
* 1. Create webhook endpoint in Stripe Dashboard pointing to /api/billing/stripe-webhook
|
|
14
|
+
* 2. Copy signing secret → `.env` STRIPE_WEBHOOK_SECRET
|
|
15
|
+
* 3. Test locally: `stripe listen --forward-to localhost:3000/api/billing/stripe-webhook`
|
|
16
|
+
*/
|
|
17
|
+
const STRIPE_SECRET = process.env.STRIPE_WEBHOOK_SECRET ?? ''
|
|
18
|
+
|
|
19
|
+
export const POST = defineWebhook({
|
|
20
|
+
verify: ({ rawBody, headers }) => {
|
|
21
|
+
if (STRIPE_SECRET === '') return false
|
|
22
|
+
const sigHeader = headers.get('stripe-signature') ?? ''
|
|
23
|
+
// Stripe format: `t=<unix-ts>,v1=<hash>` (potentially also v0)
|
|
24
|
+
const parts: Record<string, string> = {}
|
|
25
|
+
for (const pair of sigHeader.split(',')) {
|
|
26
|
+
const [k, v] = pair.split('=')
|
|
27
|
+
if (k && v) parts[k.trim()] = v.trim()
|
|
28
|
+
}
|
|
29
|
+
const t = parts['t']
|
|
30
|
+
const v1 = parts['v1']
|
|
31
|
+
if (!t || !v1) return false
|
|
32
|
+
const signedPayload = `${t}.${rawBody}`
|
|
33
|
+
const expected = createHmac('sha256', STRIPE_SECRET).update(signedPayload).digest('hex')
|
|
34
|
+
try {
|
|
35
|
+
return timingSafeEqual(Buffer.from(v1, 'utf-8'), Buffer.from(expected, 'utf-8'))
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
inputSchema: z.object({ type: z.string(), data: z.unknown() }),
|
|
41
|
+
handler: async ({ input, log }) => {
|
|
42
|
+
log.info({ msg: 'stripe webhook received', type: input.type })
|
|
43
|
+
// TODO: dispatch by input.type:
|
|
44
|
+
// - 'checkout.session.completed' → activate subscription
|
|
45
|
+
// - 'invoice.paid' → extend access
|
|
46
|
+
// - 'invoice.payment_failed' → notify user + retry plan
|
|
47
|
+
return Response.json({ received: true })
|
|
48
|
+
},
|
|
49
|
+
})
|