ConusAI vendor 81941888f
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from './server'
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'server-only'
|
||||
import { ConusAIClient } from '@conusai-sdk/node-sdk'
|
||||
import { createAuditClient, defineAuditEvents, type AuditClient } from '@conusai-sdk/node-sdk/audit'
|
||||
import { getEnv } from '@/shared/config/env'
|
||||
|
||||
/** The actions this app writes to the audit log (plan §4.4): one definition, typed `log()`. */
|
||||
export const events = defineAuditEvents({
|
||||
'note.created': { target: 'note' },
|
||||
'note.updated': { target: 'note' },
|
||||
'note.deleted': { target: 'note' },
|
||||
'note.announced': { target: 'note' },
|
||||
'file.uploaded': { target: 'file' },
|
||||
'file.deleted': { target: 'file' },
|
||||
})
|
||||
|
||||
export type AppAuditClient = AuditClient<typeof events>
|
||||
|
||||
let client: AppAuditClient | undefined
|
||||
|
||||
/** The audit client. It reads `CONUSAI_API_URL` and `CONUSAI_API_TOKEN` itself (D8). */
|
||||
export function audit(): AppAuditClient {
|
||||
client ??= createAuditClient({ events })
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* The project's own audit events, newest first, one page. The deployment token
|
||||
* carries `audit:read` (showcase D9), so the platform answers for this project only.
|
||||
*/
|
||||
export async function readAuditEvents(options: { cursor?: string; limit?: number } = {}) {
|
||||
const env = getEnv()
|
||||
const platform = new ConusAIClient({ baseUrl: env.CONUSAI_API_URL, apiKey: env.CONUSAI_API_TOKEN })
|
||||
const { data } = await platform.readEvents({ query: options, throwOnError: true })
|
||||
if (!data) throw new Error('the platform returned no audit page')
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './server'
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'server-only'
|
||||
import { createClient, type BlobClient } from '@conusai-sdk/blob'
|
||||
|
||||
let client: BlobClient | undefined
|
||||
|
||||
/** The Blob Storage client. It reads `CONUSAI_API_URL` and `CONUSAI_API_TOKEN` itself (D8). */
|
||||
export function blob(): BlobClient {
|
||||
client ??= createClient()
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './server'
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'server-only'
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
import { getEnv } from '@/shared/config/env'
|
||||
|
||||
export type Db = ReturnType<typeof drizzle>
|
||||
|
||||
let connection: Db | undefined
|
||||
|
||||
/** The Postgres connection every server module shares, opened on first use. */
|
||||
export function db(): Db {
|
||||
connection ??= drizzle(postgres(getEnv().POSTGRES_URL))
|
||||
return connection
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// The public API of the shared server clients. Each one is `server-only`; a
|
||||
// client component that imports this file fails the build, which is the guard.
|
||||
export * from './audit'
|
||||
export * from './blob'
|
||||
export * from './db'
|
||||
export * from './kv'
|
||||
export * from './nats'
|
||||
export * from './observe'
|
||||
export * from './platform'
|
||||
export * from './qdrant'
|
||||
export * from './s3'
|
||||
export * from './usage'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './server'
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'server-only'
|
||||
import { createClient, type KV } from '@conusai-sdk/kv'
|
||||
import { getEnv, modules } from '@/shared/config/env'
|
||||
|
||||
let client: KV | undefined
|
||||
|
||||
/**
|
||||
* The KV client when the KV module is linked (D13: `REDIS_URL` present), else
|
||||
* `null`. Callers render the enable card instead of calling a dead service.
|
||||
*/
|
||||
export function kv(): KV | null {
|
||||
if (!modules(getEnv()).kv) return null
|
||||
client ??= createClient()
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './server'
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'server-only'
|
||||
import { connect, type NatsConnection } from 'nats'
|
||||
import { getEnv, modules } from '@/shared/config/env'
|
||||
|
||||
let connection: Promise<NatsConnection> | undefined
|
||||
|
||||
/** The NATS connection, or `null` when the NATS module is not linked (D13). */
|
||||
export function nats(): Promise<NatsConnection> | null {
|
||||
const env = getEnv()
|
||||
const servers = env.NATS_URL
|
||||
if (!modules(env).nats || !servers) return null
|
||||
if (!connection) connection = connect(env.NATS_TOKEN ? { servers, token: env.NATS_TOKEN } : { servers })
|
||||
return connection
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { serverErrors, withRequest } from './server'
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'server-only'
|
||||
import { init, withRequest, type ErrorTrackingClient } from '@conusai-sdk/observe-node'
|
||||
import { getEnv } from '@/shared/config/env'
|
||||
|
||||
let client: ErrorTrackingClient | undefined
|
||||
|
||||
/**
|
||||
* The server error client, bound to this environment's DSN and release (D8). Server
|
||||
* errors leave with the request id of the request that raised them.
|
||||
*/
|
||||
export function serverErrors(): ErrorTrackingClient {
|
||||
if (!client) {
|
||||
const env = getEnv()
|
||||
client = init({ dsn: env.CONUSAI_DSN, release: env.CONUSAI_RELEASE })
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
export { withRequest }
|
||||
@@ -0,0 +1 @@
|
||||
export * from './server'
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'server-only'
|
||||
import { getPlatformInfo, getServiceType, type ServiceType } from '@conusai-sdk/api'
|
||||
import { getEnv } from '@/shared/config/env'
|
||||
|
||||
function auth() {
|
||||
const env = getEnv()
|
||||
return { baseUrl: env.CONUSAI_API_URL, headers: { Authorization: `Bearer ${env.CONUSAI_API_TOKEN}` } }
|
||||
}
|
||||
|
||||
/** The platform's own description, read with this project's deployment token. */
|
||||
export async function platformInfo() {
|
||||
const { data } = await getPlatformInfo({ ...auth(), throwOnError: true })
|
||||
if (!data) throw new Error('the platform returned no description')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog entry of a service type: the text an enable card shows, read from the
|
||||
* platform rather than written here (plan D13).
|
||||
*/
|
||||
export async function serviceCatalogEntry(serviceType: ServiceType) {
|
||||
const { data } = await getServiceType({ ...auth(), path: { service_type: serviceType }, throwOnError: true })
|
||||
if (!data) throw new Error(`the platform returned no catalog entry for ${serviceType}`)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './server'
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'server-only'
|
||||
|
||||
/** What the Qdrant REST API needs from this app: where it is and how to authenticate. */
|
||||
export type QdrantConfig = { url: string; apiKey?: string | undefined; collection: string }
|
||||
|
||||
/** One note in the vector index: its id, its text's vector and its title. */
|
||||
export type QdrantPoint = { id: string; vector: number[]; payload: { title: string } }
|
||||
|
||||
/** Creates the collection if it is missing, with cosine distance at the vector's size. */
|
||||
export async function ensureCollection(config: QdrantConfig, size: number, fetcher: typeof fetch = fetch): Promise<void> {
|
||||
const response = await fetcher(`${config.url}/collections/${config.collection}`, {
|
||||
method: 'PUT',
|
||||
headers: qdrantHeaders(config),
|
||||
body: JSON.stringify({ vectors: { size, distance: 'Cosine' } }),
|
||||
})
|
||||
// 409 means the collection already exists: that is the state we want.
|
||||
if (!response.ok && response.status !== 409) throw new Error(`qdrant collection answered ${response.status}`)
|
||||
}
|
||||
|
||||
export async function upsertPoints(config: QdrantConfig, points: QdrantPoint[], fetcher: typeof fetch = fetch): Promise<void> {
|
||||
const response = await fetcher(`${config.url}/collections/${config.collection}/points?wait=true`, {
|
||||
method: 'PUT',
|
||||
headers: qdrantHeaders(config),
|
||||
body: JSON.stringify({ points }),
|
||||
})
|
||||
if (!response.ok) throw new Error(`qdrant upsert answered ${response.status}`)
|
||||
}
|
||||
|
||||
export async function searchPoints(
|
||||
config: QdrantConfig,
|
||||
vector: number[],
|
||||
limit: number,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<Array<{ id: string; score: number; payload: { title: string } }>> {
|
||||
const response = await fetcher(`${config.url}/collections/${config.collection}/points/search`, {
|
||||
method: 'POST',
|
||||
headers: qdrantHeaders(config),
|
||||
body: JSON.stringify({ vector, limit, with_payload: true }),
|
||||
})
|
||||
if (!response.ok) throw new Error(`qdrant search answered ${response.status}`)
|
||||
const body = (await response.json()) as { result: Array<{ id: string; score: number; payload: { title: string } }> }
|
||||
return body.result
|
||||
}
|
||||
|
||||
function qdrantHeaders(config: QdrantConfig): Record<string, string> {
|
||||
return config.apiKey ? { 'content-type': 'application/json', 'api-key': config.apiKey } : { 'content-type': 'application/json' }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './server'
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'server-only'
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import { getEnv, modules } from '@/shared/config/env'
|
||||
|
||||
let client: S3Client | undefined
|
||||
|
||||
/** The dedicated S3 client, or `null` when the S3 module is not linked (D13). */
|
||||
export function s3(): { client: S3Client; bucket: string } | null {
|
||||
const env = getEnv()
|
||||
if (!modules(env).s3 || !env.S3_BUCKET || !env.AWS_ENDPOINT_URL) return null
|
||||
const credentials =
|
||||
env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY
|
||||
? { accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY }
|
||||
: undefined
|
||||
client ??= new S3Client(
|
||||
credentials
|
||||
? { endpoint: env.AWS_ENDPOINT_URL, forcePathStyle: true, region: 'us-east-1', credentials }
|
||||
: { endpoint: env.AWS_ENDPOINT_URL, forcePathStyle: true, region: 'us-east-1' },
|
||||
)
|
||||
return { client, bucket: env.S3_BUCKET }
|
||||
}
|
||||
|
||||
export { PutObjectCommand }
|
||||
@@ -0,0 +1 @@
|
||||
export { recordUsage, type UsageEvent, type UsageResult } from './server'
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { recordUsage, type UsageEvent } from '@/shared/api/usage/server'
|
||||
|
||||
const config = { baseUrl: 'https://console.example.com', token: 'dt_fixture' }
|
||||
const event: UsageEvent = {
|
||||
idempotency_key: 'upload:f1',
|
||||
customer: { external_id: 'showcase' },
|
||||
meter: 'uploads',
|
||||
quantity: '1',
|
||||
}
|
||||
|
||||
describe('recordUsage', () => {
|
||||
it('posts one batch to the token route with the token as a bearer credential', async () => {
|
||||
const calls: { url: string; init: RequestInit }[] = []
|
||||
const fetcher = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init })
|
||||
return new Response(JSON.stringify({ accepted: 1, duplicates: 0, rejected: [] }), { status: 202 })
|
||||
}) as typeof fetch
|
||||
|
||||
const result = await recordUsage(config, [event], fetcher)
|
||||
|
||||
expect(result).toEqual({ accepted: 1, duplicates: 0, rejected: [] })
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.url).toBe('https://console.example.com/_conusai/usage/events')
|
||||
expect(calls[0]?.init.method).toBe('POST')
|
||||
expect(new Headers(calls[0]?.init.headers).get('authorization')).toBe('Bearer dt_fixture')
|
||||
expect(JSON.parse(String(calls[0]?.init.body))).toEqual({ events: [event] })
|
||||
})
|
||||
|
||||
it('treats anything but 202 as a failure, so the caller can resend the same key', async () => {
|
||||
const fetcher = (async () => new Response('{}', { status: 403 })) as typeof fetch
|
||||
await expect(recordUsage(config, [event], fetcher)).rejects.toThrow(/403/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'server-only'
|
||||
|
||||
/** One usage event, as the platform's metering ingest reads it (plan D9). */
|
||||
export type UsageEvent = {
|
||||
idempotency_key: string
|
||||
customer: { external_id: string }
|
||||
meter: string
|
||||
quantity: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** The ingest's answer: what was stored, what was already there, and what was refused. */
|
||||
export type UsageResult = {
|
||||
accepted: number
|
||||
duplicates: number
|
||||
rejected: unknown[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends usage events with the deployment token (showcase S4.6, D9). A retry with the
|
||||
* same idempotency key stores nothing twice, so a failed send can be sent again.
|
||||
* Anything but 202 is an error: the platform answers 422 only when every event was refused.
|
||||
*/
|
||||
export async function recordUsage(
|
||||
config: { baseUrl: string; token: string },
|
||||
events: UsageEvent[],
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<UsageResult> {
|
||||
const response = await fetcher(`${config.baseUrl}/_conusai/usage/events`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${config.token}` },
|
||||
body: JSON.stringify({ events }),
|
||||
})
|
||||
if (response.status !== 202) throw new Error(`usage ingest answered ${response.status}`)
|
||||
return (await response.json()) as UsageResult
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { modules, parseEnv } from '@/shared/config/env'
|
||||
|
||||
const required = {
|
||||
CONUSAI_API_URL: 'https://console.example.com',
|
||||
CONUSAI_API_TOKEN: 'dt_fixture',
|
||||
CONUSAI_PROJECT_ID: '42',
|
||||
CONUSAI_DSN: 'https://key@errors.example.com/1',
|
||||
CONUSAI_RELEASE: 'abc123',
|
||||
POSTGRES_URL: 'postgres://app@db.example.com/app',
|
||||
}
|
||||
|
||||
describe('parseEnv', () => {
|
||||
it('accepts the required contract and leaves every optional module off', () => {
|
||||
const env = parseEnv(required)
|
||||
expect(modules(env)).toEqual({ kv: false, nats: false, qdrant: false, git: false, s3: false })
|
||||
})
|
||||
|
||||
it('names the missing required variable', () => {
|
||||
const rest: Record<string, string | undefined> = { ...required }
|
||||
delete rest.CONUSAI_API_TOKEN
|
||||
expect(() => parseEnv(rest)).toThrow(/CONUSAI_API_TOKEN/)
|
||||
})
|
||||
|
||||
it('lights a module when its variable is present', () => {
|
||||
const env = parseEnv({ ...required, REDIS_URL: 'redis://cache.example.com:6379' })
|
||||
expect(modules(env).kv).toBe(true)
|
||||
})
|
||||
|
||||
it('lights the S3 module only when both its endpoint and bucket are present', () => {
|
||||
expect(modules(parseEnv({ ...required, AWS_ENDPOINT_URL: 'https://s3.example.com' })).s3).toBe(false)
|
||||
expect(modules(parseEnv({ ...required, AWS_ENDPOINT_URL: 'https://s3.example.com', S3_BUCKET: 'exports' })).s3).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'server-only'
|
||||
import { z } from 'zod'
|
||||
|
||||
// The SDK environment contract (showcase D8): the names every deployment receives.
|
||||
const required = z.object({
|
||||
CONUSAI_API_URL: z.string().url(),
|
||||
CONUSAI_API_TOKEN: z.string().min(1),
|
||||
CONUSAI_PROJECT_ID: z.string().min(1),
|
||||
CONUSAI_DSN: z.string().url(),
|
||||
CONUSAI_RELEASE: z.string().min(1),
|
||||
POSTGRES_URL: z.string().url(),
|
||||
})
|
||||
|
||||
// Optional services (D13): each one lights a widget when its variables are present.
|
||||
const optional = z.object({
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
NATS_URL: z.string().optional(),
|
||||
NATS_TOKEN: z.string().optional(),
|
||||
QDRANT_URL: z.string().url().optional(),
|
||||
QDRANT_API_KEY: z.string().optional(),
|
||||
GIT_URL: z.string().url().optional(),
|
||||
AWS_ENDPOINT_URL: z.string().url().optional(),
|
||||
S3_BUCKET: z.string().min(1).optional(),
|
||||
AWS_ACCESS_KEY_ID: z.string().optional(),
|
||||
AWS_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
})
|
||||
|
||||
const schema = required.merge(optional)
|
||||
|
||||
export type Env = z.infer<typeof schema>
|
||||
|
||||
/** Parse the environment. A missing required name throws with its name in the message. */
|
||||
export function parseEnv(source: Record<string, string | undefined>): Env {
|
||||
const result = schema.safeParse(source)
|
||||
if (!result.success) {
|
||||
const names = result.error.issues.map((issue) => issue.path.join('.')).join(', ')
|
||||
throw new Error(`invalid environment: ${names}`)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
let parsed: Env | undefined
|
||||
|
||||
/** The environment, parsed once on first use. */
|
||||
export function getEnv(): Env {
|
||||
parsed ??= parseEnv(process.env)
|
||||
return parsed
|
||||
}
|
||||
|
||||
/** Which optional modules are linked, for the widgets that depend on them. */
|
||||
export function modules(env: Env) {
|
||||
return {
|
||||
kv: Boolean(env.REDIS_URL),
|
||||
nats: Boolean(env.NATS_URL),
|
||||
qdrant: Boolean(env.QDRANT_URL),
|
||||
git: Boolean(env.GIT_URL),
|
||||
s3: Boolean(env.AWS_ENDPOINT_URL && env.S3_BUCKET),
|
||||
} as const
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { getEnv, modules, parseEnv, type Env } from './env'
|
||||
@@ -0,0 +1,64 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { connect } from 'nats'
|
||||
import { CreateBucketCommand, GetObjectCommand } from '@aws-sdk/client-s3'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The live check (plan S4.7 gap 4): NATS, Qdrant and S3 exercised against real
|
||||
// services. It runs when their variables are present, the same names a deployment
|
||||
// receives (D8, D13), and is skipped otherwise.
|
||||
const live = Boolean(process.env.NATS_URL && process.env.QDRANT_URL && process.env.AWS_ENDPOINT_URL)
|
||||
|
||||
describe.runIf(live)('optional modules against live services', () => {
|
||||
type Optional = typeof import('@/shared/lib/optional')
|
||||
type Api = typeof import('@/shared/api')
|
||||
let optional: Optional
|
||||
let api: Api
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv('S3_BUCKET', process.env.S3_BUCKET ?? 'showcase-live')
|
||||
optional = await import('@/shared/lib/optional')
|
||||
api = await import('@/shared/api')
|
||||
const target = api.s3()
|
||||
if (!target) throw new Error('S3 is not linked')
|
||||
await target.client.send(new CreateBucketCommand({ Bucket: target.bucket })).catch((error: { name?: string }) => {
|
||||
if (error.name !== 'BucketAlreadyOwnedByYou' && error.name !== 'BucketAlreadyExists') throw error
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => vi.unstubAllEnvs())
|
||||
|
||||
it('publishes note.created on NATS and a subscriber receives it', async () => {
|
||||
const subscriber = await connect({ servers: process.env.NATS_URL as string })
|
||||
const sub = subscriber.subscribe('showcase.notes.created')
|
||||
const id = randomUUID()
|
||||
await optional.publishNoteCreated({ id, title: 'live note' })
|
||||
await subscriber.flush()
|
||||
const received = await Promise.race([
|
||||
(async () => {
|
||||
for await (const message of sub) return JSON.parse(new TextDecoder().decode(message.data)) as { id: string; title: string }
|
||||
return undefined
|
||||
})(),
|
||||
new Promise((resolve) => setTimeout(() => resolve(undefined), 3000)),
|
||||
])
|
||||
await subscriber.close()
|
||||
expect(received).toEqual({ id, title: 'live note' })
|
||||
})
|
||||
|
||||
it('indexes a note in Qdrant and finds it as the top similarity hit', async () => {
|
||||
const id = randomUUID()
|
||||
const title = `budget ${id.slice(0, 8)}`
|
||||
await optional.indexNote({ id, title, body: 'quarterly budget review with the finance team' })
|
||||
const hits = await optional.similarNotes(`${title} quarterly budget review`, 1)
|
||||
expect(hits?.[0]?.id).toBe(id)
|
||||
})
|
||||
|
||||
it('writes an export object to the S3 bucket and reads it back', async () => {
|
||||
const notes = [{ title: 'live export' }]
|
||||
const key = await optional.exportToS3('live-visitor', notes)
|
||||
expect(key).toMatch(/^exports\/live-visitor\/.+\.json$/)
|
||||
const target = api.s3()
|
||||
if (!target || !key) throw new Error('S3 is not linked')
|
||||
const object = await target.client.send(new GetObjectCommand({ Bucket: target.bucket, Key: key }))
|
||||
expect(JSON.parse(await object.Body!.transformToString())).toEqual(notes)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The optional-module helpers run against a fake KV store: the modules' clients are
|
||||
// replaced, so each test asserts what the helper asked for.
|
||||
const mocks = vi.hoisted(() => {
|
||||
const store = new Map<string, number>()
|
||||
return {
|
||||
store,
|
||||
kvClient: null as null | {
|
||||
incr: (key: string) => Promise<number>
|
||||
expire: (key: string, seconds: number) => Promise<number>
|
||||
get: <T>(key: string) => Promise<T | null>
|
||||
},
|
||||
expire: vi.fn(async () => 1),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/api', () => ({
|
||||
kv: () => mocks.kvClient,
|
||||
nats: () => null,
|
||||
s3: () => null,
|
||||
ensureCollection: vi.fn(async () => {}),
|
||||
upsertPoints: vi.fn(async () => {}),
|
||||
searchPoints: vi.fn(async () => []),
|
||||
}))
|
||||
vi.mock('@/shared/config', () => ({
|
||||
getEnv: () => ({ QDRANT_URL: undefined }),
|
||||
}))
|
||||
|
||||
import { consumeUploadQuota, embed, UPLOADS_PER_MINUTE, uploadsLeft } from '@/shared/lib/optional'
|
||||
|
||||
function fakeKv() {
|
||||
mocks.kvClient = {
|
||||
incr: async (key: string) => {
|
||||
const next = (mocks.store.get(key) ?? 0) + 1
|
||||
mocks.store.set(key, next)
|
||||
return next
|
||||
},
|
||||
expire: mocks.expire,
|
||||
get: async <T>(key: string) => (mocks.store.has(key) ? (mocks.store.get(key) as T) : null),
|
||||
}
|
||||
}
|
||||
|
||||
describe('consumeUploadQuota', () => {
|
||||
beforeEach(() => {
|
||||
mocks.store.clear()
|
||||
mocks.expire.mockClear()
|
||||
fakeKv()
|
||||
})
|
||||
|
||||
it('allows the uploads of the minute up to the limit, refuses the next, and sets the window once', async () => {
|
||||
let last: { allowed: boolean; remaining: number } | null = null
|
||||
for (let i = 0; i < UPLOADS_PER_MINUTE; i += 1) last = await consumeUploadQuota('visitor-1')
|
||||
expect(last).toEqual({ allowed: true, remaining: 0 })
|
||||
|
||||
expect(await consumeUploadQuota('visitor-1')).toEqual({ allowed: false, remaining: 0 })
|
||||
expect(mocks.expire).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('counts each visitor separately', async () => {
|
||||
await consumeUploadQuota('visitor-1')
|
||||
expect(await uploadsLeft('visitor-2')).toBe(UPLOADS_PER_MINUTE)
|
||||
})
|
||||
|
||||
it('is null, and counts nothing, when KV is not linked', async () => {
|
||||
mocks.kvClient = null
|
||||
expect(await consumeUploadQuota('visitor-1')).toBeNull()
|
||||
expect(await uploadsLeft('visitor-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('embed', () => {
|
||||
const dot = (x: number[], y: number[]) => x.reduce((sum, value, i) => sum + value * (y[i] ?? 0), 0)
|
||||
|
||||
it('is stable for the same text, and orthogonal for text with no word in common', () => {
|
||||
const a = embed('plan the launch')
|
||||
expect(embed('plan the launch')).toEqual(a)
|
||||
expect(dot(a, a)).toBeCloseTo(1)
|
||||
expect(dot(a, embed('zebra quartz'))).toBeCloseTo(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'server-only'
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3'
|
||||
import { ensureCollection, kv, nats, s3, searchPoints, upsertPoints, type QdrantConfig } from '@/shared/api'
|
||||
import { getEnv } from '@/shared/config'
|
||||
|
||||
/** Uploads a visitor may make in one minute, when the KV module is linked (plan §4.4). */
|
||||
export const UPLOADS_PER_MINUTE = 5
|
||||
|
||||
/**
|
||||
* Counts one upload against the visitor's minute. `null` when KV is not linked: the
|
||||
* upload then proceeds unlimited, because there is no store to count in.
|
||||
*/
|
||||
export async function consumeUploadQuota(visitor: string): Promise<{ allowed: boolean; remaining: number } | null> {
|
||||
const store = kv()
|
||||
if (!store) return null
|
||||
const key = `uploads:${visitor}:${Math.floor(Date.now() / 60_000)}`
|
||||
const count = await store.incr(key)
|
||||
if (count === 1) await store.expire(key, 60)
|
||||
return { allowed: count <= UPLOADS_PER_MINUTE, remaining: Math.max(0, UPLOADS_PER_MINUTE - count) }
|
||||
}
|
||||
|
||||
/** The uploads a visitor has left this minute, read without counting one. `null` without KV. */
|
||||
export async function uploadsLeft(visitor: string): Promise<number | null> {
|
||||
const store = kv()
|
||||
if (!store) return null
|
||||
const key = `uploads:${visitor}:${Math.floor(Date.now() / 60_000)}`
|
||||
const count = Number((await store.get<number>(key)) ?? 0)
|
||||
return Math.max(0, UPLOADS_PER_MINUTE - count)
|
||||
}
|
||||
|
||||
/** Publishes "a note was created" on NATS when it is linked; a no-op otherwise. */
|
||||
export async function publishNoteCreated(note: { id: string; title: string }): Promise<void> {
|
||||
const connection = nats()
|
||||
if (!connection) return
|
||||
(await connection).publish('showcase.notes.created', new TextEncoder().encode(JSON.stringify(note)))
|
||||
}
|
||||
|
||||
/** The Qdrant config, or `null` when the Qdrant module is not linked. */
|
||||
function qdrantConfig(): QdrantConfig | null {
|
||||
const env = getEnv()
|
||||
if (!env.QDRANT_URL) return null
|
||||
return { url: env.QDRANT_URL, apiKey: env.QDRANT_API_KEY, collection: 'showcase_notes' }
|
||||
}
|
||||
|
||||
/**
|
||||
* A deterministic bag-of-words vector: each word hashes into one of 64 buckets,
|
||||
* counts are normalised. No embedding service is needed, so the similarity search
|
||||
* runs on any install that links Qdrant.
|
||||
*/
|
||||
export function embed(text: string, size = 64): number[] {
|
||||
const vector = new Array<number>(size).fill(0)
|
||||
for (const word of text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)) {
|
||||
let hash = 0
|
||||
for (const ch of word) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
|
||||
vector[hash % size] = (vector[hash % size] ?? 0) + 1
|
||||
}
|
||||
const norm = Math.hypot(...vector) || 1
|
||||
return vector.map((value) => value / norm)
|
||||
}
|
||||
|
||||
export async function indexNote(note: { id: string; title: string; body: string }): Promise<void> {
|
||||
const config = qdrantConfig()
|
||||
if (!config) return
|
||||
await ensureCollection(config, 64)
|
||||
await upsertPoints(config, [{ id: note.id, vector: embed(`${note.title} ${note.body}`), payload: { title: note.title } }])
|
||||
}
|
||||
|
||||
export async function similarNotes(text: string, limit = 5) {
|
||||
const config = qdrantConfig()
|
||||
if (!config) return null
|
||||
return searchPoints(config, embed(text), limit)
|
||||
}
|
||||
|
||||
/** Writes the visitor's notes as one JSON object to the S3 bucket; `null` when S3 is not linked. */
|
||||
export async function exportToS3(visitor: string, notes: unknown): Promise<string | null> {
|
||||
const target = s3()
|
||||
if (!target) return null
|
||||
const key = `exports/${visitor}/${new Date().toISOString()}.json`
|
||||
await target.client.send(
|
||||
new PutObjectCommand({ Bucket: target.bucket, Key: key, Body: JSON.stringify(notes), ContentType: 'application/json' }),
|
||||
)
|
||||
return key
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'server-only'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getEnv } from '@/shared/config/env'
|
||||
import { VISITOR_COOKIE, newVisitorId, readVisitorId, signVisitorId } from '@/shared/lib/visitor'
|
||||
|
||||
// The HMAC key is the deployment token (D8): every container holds it and it
|
||||
// never reaches the browser, so the cookie cannot be forged without it.
|
||||
const ONE_YEAR = 60 * 60 * 24 * 365
|
||||
|
||||
/**
|
||||
* The current visitor's id. A visitor with no valid cookie gets a new one, set
|
||||
* on this response. Call it only from a server action or a route handler:
|
||||
* Next allows cookies to be set there and not during rendering.
|
||||
*/
|
||||
export async function ensureVisitor(): Promise<string> {
|
||||
const jar = await cookies()
|
||||
const secret = getEnv().CONUSAI_API_TOKEN
|
||||
const existing = readVisitorId(jar.get(VISITOR_COOKIE)?.value, secret)
|
||||
if (existing) return existing
|
||||
const id = newVisitorId()
|
||||
jar.set(VISITOR_COOKIE, signVisitorId(id, secret), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: ONE_YEAR,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* The current visitor's id, or `null` when there is none. Reads the cookie and
|
||||
* never sets one, so it is safe during rendering.
|
||||
*/
|
||||
export async function currentVisitor(): Promise<string | null> {
|
||||
const jar = await cookies()
|
||||
return readVisitorId(jar.get(VISITOR_COOKIE)?.value, getEnv().CONUSAI_API_TOKEN)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { newVisitorId, readVisitorId, signVisitorId } from '@/shared/lib/visitor'
|
||||
|
||||
const secret = 'fixture-secret'
|
||||
|
||||
describe('visitor cookie', () => {
|
||||
it('reads back the id it signed', () => {
|
||||
const id = newVisitorId()
|
||||
expect(readVisitorId(signVisitorId(id, secret), secret)).toBe(id)
|
||||
})
|
||||
|
||||
it('rejects a value signed with another secret', () => {
|
||||
const id = newVisitorId()
|
||||
expect(readVisitorId(signVisitorId(id, 'other'), secret)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a forged id under a valid signature', () => {
|
||||
const signed = signVisitorId(newVisitorId(), secret)
|
||||
const forged = `${newVisitorId()}${signed.slice(signed.lastIndexOf('.'))}`
|
||||
expect(readVisitorId(forged, secret)).toBeNull()
|
||||
})
|
||||
|
||||
it('treats an absent or malformed cookie as no visitor', () => {
|
||||
expect(readVisitorId(undefined, secret)).toBeNull()
|
||||
expect(readVisitorId('no-dot-here', secret)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
/** The cookie that identifies an anonymous visitor (showcase D16). */
|
||||
export const VISITOR_COOKIE = 'sc_vid'
|
||||
|
||||
/** A new visitor id: random, opaque, and never derived from anything the visitor sent. */
|
||||
export function newVisitorId(): string {
|
||||
return randomUUID()
|
||||
}
|
||||
|
||||
/** The cookie value for `id`: the id and an HMAC over it, so a forged id fails `readVisitorId`. */
|
||||
export function signVisitorId(id: string, secret: string): string {
|
||||
return `${id}.${mac(id, secret)}`
|
||||
}
|
||||
|
||||
/** The visitor id in a cookie value, or `null` when the value is absent or its signature fails. */
|
||||
export function readVisitorId(value: string | undefined, secret: string): string | null {
|
||||
if (!value) return null
|
||||
const dot = value.lastIndexOf('.')
|
||||
if (dot <= 0) return null
|
||||
const id = value.slice(0, dot)
|
||||
const given = Buffer.from(value.slice(dot + 1))
|
||||
const expected = Buffer.from(mac(id, secret))
|
||||
if (given.length !== expected.length) return null
|
||||
return timingSafeEqual(given, expected) ? id : null
|
||||
}
|
||||
|
||||
function mac(id: string, secret: string): string {
|
||||
return createHmac('sha256', secret).update(id).digest('base64url')
|
||||
}
|
||||
Reference in New Issue
Block a user