import { randomBytes } from "node:crypto";
function normalizeCode(code) {
	return code.trim().toLowerCase();
}
function nowIso() {
	return (/* @__PURE__ */ new Date()).toISOString();
}
/**
* True when the code exists, is active, not past its expiry, and not at its
* max_uses cap. Mirrors the checks redeemCode enforces, so a code that passes
* here will actually be redeemable (no "validated then redeem fails" gap).
*/
async function isCodeUsable(db, code) {
	const now = nowIso();
	const normalized = normalizeCode(code);
	const row = await db.selectFrom("invite_codes").select(["code", "max_uses"]).where("code", "=", normalized).where("active", "=", true).where((eb) => eb.or([eb("expires_at", "is", null), eb("expires_at", ">", now)])).executeTakeFirst();
	if (!row) return false;
	if (row.max_uses != null) {
		const { count } = await db.selectFrom("invite_redemptions").select((eb) => eb.fn.count("user_did").as("count")).where("code", "=", normalized).executeTakeFirstOrThrow();
		if (Number(count) >= row.max_uses) return false;
	}
	return true;
}
/**
* Redeem a code for a DID. Idempotent: a DID re-redeeming the same code is a
* no-op success. Enforces `max_uses` (distinct redeeming DIDs) when set.
*/
async function redeemCode(db, code, did) {
	const normalized = normalizeCode(code);
	return db.transaction().execute(async (trx) => {
		const now = nowIso();
		const codeRow = await trx.selectFrom("invite_codes").select(["code", "max_uses"]).where("code", "=", normalized).where("active", "=", true).where((eb) => eb.or([eb("expires_at", "is", null), eb("expires_at", ">", now)])).forUpdate().executeTakeFirst();
		if (!codeRow) return { ok: false };
		if (await trx.selectFrom("invite_redemptions").select("user_did").where("code", "=", normalized).where("user_did", "=", did).executeTakeFirst()) return { ok: true };
		if (codeRow.max_uses != null) {
			const { count } = await trx.selectFrom("invite_redemptions").select((eb) => eb.fn.count("user_did").as("count")).where("code", "=", normalized).executeTakeFirstOrThrow();
			if (Number(count) >= codeRow.max_uses) return { ok: false };
		}
		const accessExpires = new Date(Date.now() + 720 * 60 * 60 * 1e3).toISOString();
		await trx.insertInto("invite_redemptions").values({
			code: normalized,
			user_did: did,
			redeemed_at: now,
			access_expires: accessExpires
		}).onConflict((oc) => oc.columns(["code", "user_did"]).doNothing()).execute();
		return { ok: true };
	});
}
/** Most recent non-expired redemption for a DID, with its cohort label. */
async function getAccessStatus(db, did) {
	const now = nowIso();
	const row = await db.selectFrom("invite_redemptions as r").innerJoin("invite_codes as c", "c.code", "r.code").select([
		"r.code as code",
		"c.label as label",
		"r.access_expires as access_expires"
	]).where("r.user_did", "=", did).where((eb) => eb.or([eb("r.access_expires", "is", null), eb("r.access_expires", ">", now)])).orderBy("r.redeemed_at", "desc").limit(1).executeTakeFirst();
	if (!row) return {
		allowed: false,
		hasAttachedKey: false
	};
	return {
		allowed: true,
		code: row.code,
		label: row.label ?? void 0,
		accessExpires: row.access_expires ?? void 0,
		hasAttachedKey: await hasAttachedKeyForDid(db, did)
	};
}
/**
* True when the DID has at least one active (non-expired) redemption whose
* code carries an attached key. Mirrors the selection in
* getRedeemedKeyCiphertext so the UI's `hasAttachedKey` and the actual
* injection agree.
*/
async function hasAttachedKeyForDid(db, did) {
	return await getRedeemedKeyCiphertext(db, did) !== null;
}
/**
* The encrypted attached key for the DID's most-recent active redemption whose
* code carries one, or null if there is none. "Active" means the redemption's
* access window has not expired.
*/
async function getRedeemedKeyCiphertext(db, did) {
	const now = nowIso();
	return (await db.selectFrom("invite_redemptions as r").innerJoin("invite_codes as c", "c.code", "r.code").select(["c.anthropic_key_ciphertext as ciphertext"]).where("r.user_did", "=", did).where("c.anthropic_key_ciphertext", "is not", null).where((eb) => eb.or([eb("r.access_expires", "is", null), eb("r.access_expires", ">", now)])).orderBy("r.redeemed_at", "desc").limit(1).executeTakeFirst())?.ciphertext ?? null;
}
async function codeView(db, code) {
	const row = await db.selectFrom("invite_codes as c").leftJoin("invite_redemptions as r", "r.code", "c.code").select((eb) => [
		"c.code as code",
		"c.label as label",
		"c.active as active",
		"c.expires_at as expires_at",
		"c.max_uses as max_uses",
		"c.created_at as created_at",
		"c.anthropic_key_ciphertext as anthropic_key_ciphertext",
		eb.fn.count("r.user_did").as("redemptions")
	]).where("c.code", "=", code).groupBy([
		"c.code",
		"c.label",
		"c.active",
		"c.expires_at",
		"c.max_uses",
		"c.created_at",
		"c.anthropic_key_ciphertext"
	]).executeTakeFirst();
	if (!row) return null;
	return {
		code: row.code,
		label: row.label,
		active: row.active,
		expiresAt: row.expires_at,
		maxUses: row.max_uses,
		createdAt: row.created_at,
		redemptions: Number(row.redemptions),
		hasAnthropicKey: row.anthropic_key_ciphertext != null
	};
}
/** All codes with their redemption counts, newest first (admin listing). */
async function listCodes(db) {
	return (await db.selectFrom("invite_codes as c").leftJoin("invite_redemptions as r", "r.code", "c.code").select((eb) => [
		"c.code as code",
		"c.label as label",
		"c.active as active",
		"c.expires_at as expires_at",
		"c.max_uses as max_uses",
		"c.created_at as created_at",
		"c.anthropic_key_ciphertext as anthropic_key_ciphertext",
		eb.fn.count("r.user_did").as("redemptions")
	]).groupBy([
		"c.code",
		"c.label",
		"c.active",
		"c.expires_at",
		"c.max_uses",
		"c.created_at",
		"c.anthropic_key_ciphertext"
	]).orderBy("c.created_at", "desc").execute()).map((row) => ({
		code: row.code,
		label: row.label,
		active: row.active,
		expiresAt: row.expires_at,
		maxUses: row.max_uses,
		createdAt: row.created_at,
		redemptions: Number(row.redemptions),
		hasAnthropicKey: row.anthropic_key_ciphertext != null
	}));
}
/**
* LIKE pattern matching any redemption DID for a wallet address, on any
* chain. The DID is `did:pkh:<networkId>:<chainId>:<address>`, so we anchor
* on the `:<address>` suffix. Addresses are stored lowercased.
*
* The address is validated to a canonical `0x`+40-hex form first: this
* rejects empty/partial inputs and, critically, any LIKE metacharacters
* (`%`, `_`) — otherwise `revokeAccess("%")` would expire every wallet's
* grants and `redemptions("%")` would dump every redemption.
*/
function addressDidPattern(address) {
	const addr = address.trim().toLowerCase();
	if (!/^0x[0-9a-f]{40}$/.test(addr)) throw new Error("INVALID_ADDRESS");
	return `%:${addr}`;
}
/**
* Redemptions, newest first. Optionally filtered by code and/or by the
* redeeming wallet address. With no filter, every redemption (audit). Admin use.
*/
async function listRedemptions(db, filter = {}) {
	let query = db.selectFrom("invite_redemptions").select([
		"code",
		"user_did",
		"redeemed_at",
		"access_expires"
	]).orderBy("redeemed_at", "desc");
	if (filter.code) query = query.where("code", "=", normalizeCode(filter.code));
	if (filter.address) query = query.where("user_did", "like", addressDidPattern(filter.address));
	return (await query.execute()).map((row) => ({
		code: row.code,
		userDid: row.user_did,
		redeemedAt: row.redeemed_at,
		accessExpires: row.access_expires
	}));
}
/**
* Revoke a wallet's currently-valid access by expiring its redemptions
* (access_expires set to now). Keeps the audit rows. Returns how many
* still-valid grants were revoked.
*
* Note: this does not bar re-acquiring access. Re-redeeming the *same* code
* is a no-op (the redemption already exists, so the expired window stays),
* but redeeming a *different* still-active code grants fresh access — disable
* the relevant codes too for a hard lockout.
*/
async function revokeAccess(db, address) {
	const now = nowIso();
	const result = await db.updateTable("invite_redemptions").set({ access_expires: now }).where("user_did", "like", addressDidPattern(address)).where((eb) => eb.or([eb("access_expires", "is", null), eb("access_expires", ">", now)])).executeTakeFirst();
	return Number(result.numUpdatedRows ?? 0);
}
/**
* Normalize an optional expiry to a canonical ISO-8601 string, so the
* string-based expiry comparisons in isCodeUsable stay correct. Throws on a
* non-parseable value rather than storing garbage.
*/
function normalizeExpiresAt(expiresAt) {
	if (expiresAt == null || expiresAt === "") return null;
	const date = new Date(expiresAt);
	if (Number.isNaN(date.getTime())) throw new Error("INVALID_EXPIRES_AT");
	return date.toISOString();
}
/** Create a code (idempotent on the code string). Returns the resulting row. */
async function createCode(db, input) {
	const normalized = normalizeCode(input.code);
	if (!normalized) throw new Error("INVALID_CODE");
	if (normalized.length > 100) throw new Error("CODE_TOO_LONG");
	const expiresAt = normalizeExpiresAt(input.expiresAt);
	await db.insertInto("invite_codes").values({
		code: normalized,
		label: input.label ?? null,
		active: true,
		expires_at: expiresAt,
		max_uses: input.maxUses ?? null,
		created_at: nowIso(),
		anthropic_key_ciphertext: input.anthropicKeyCiphertext ?? null
	}).onConflict((oc) => oc.column("code").doNothing()).execute();
	const view = await codeView(db, normalized);
	if (!view) throw new Error("CODE_NOT_FOUND");
	return view;
}
/**
* Set or clear a code's attached key. Pass the pre-encrypted ciphertext to set
* it, or null to detach. Returns the updated view, or null if the code is
* missing.
*/
async function setCodeAnthropicKey(db, code, ciphertext) {
	const normalized = normalizeCode(code);
	if (!await codeView(db, normalized)) return null;
	await db.updateTable("invite_codes").set({ anthropic_key_ciphertext: ciphertext }).where("code", "=", normalized).execute();
	return codeView(db, normalized);
}
/** Toggle a code's active flag. Returns the updated row, or null if missing. */
async function setActiveCode(db, code, active) {
	const normalized = normalizeCode(code);
	await db.updateTable("invite_codes").set({ active }).where("code", "=", normalized).execute();
	return codeView(db, normalized);
}
//#endregion
//#region subgraphs/vetra-access-codes/resolvers.ts
/**
* OpenBao transit pseudo-tenant under which attached invite-code keys are
* encrypted at rest. Distinct from any real tenant id, so the code keys get
* their own transit key (`vetra-tenant-access-codes`).
*/
const ACCESS_CODES_TRANSIT_TENANT = "access-codes";
/** Canonical did:pkh for the authenticated caller, or null if unauthenticated. */
function callerDid(ctx) {
	const u = ctx.user;
	if (!u) return null;
	return `did:pkh:${u.networkId}:${u.chainId}:${u.address.toLowerCase()}`;
}
function requireAdmin(ctx) {
	const address = ctx.user?.address?.toLowerCase();
	const admins = (process.env.ADMINS ?? "").split(",").map((a) => a.trim().toLowerCase()).filter(Boolean);
	if (!address || !admins.includes(address)) throw new Error("FORBIDDEN");
}
function createResolvers(db, deps) {
	const { transit, secretsService } = deps;
	/** Encrypt a plaintext key for at-rest storage under the access-codes key. */
	async function encryptAttachedKey(plaintext) {
		await transit.ensureTenantKey(ACCESS_CODES_TRANSIT_TENANT);
		return transit.encrypt(ACCESS_CODES_TRANSIT_TENANT, plaintext);
	}
	return {
		Query: { VetraAccessCodes: () => ({}) },
		Mutation: { VetraAccessCodes: () => ({}) },
		VetraAccessCodesQueries: {
			inviteCodeValid: (_p, { code }) => isCodeUsable(db, code),
			myAccessStatus: (_p, _a, ctx) => {
				const did = callerDid(ctx);
				if (!did) throw new Error("UNAUTHENTICATED");
				return getAccessStatus(db, did);
			},
			inviteCodes: (_p, _a, ctx) => {
				requireAdmin(ctx);
				return listCodes(db);
			},
			redemptions: (_p, { code, address }, ctx) => {
				requireAdmin(ctx);
				return listRedemptions(db, {
					code,
					address
				});
			}
		},
		VetraAccessCodesMutations: {
			redeemInviteCode: async (_p, { code }, ctx) => {
				const did = callerDid(ctx);
				if (!did) throw new Error("UNAUTHENTICATED");
				if (!(await redeemCode(db, code, did)).ok) throw new Error("INVALID_CODE");
				return getAccessStatus(db, did);
			},
			applyInviteCodeSecret: async (_p, { tenantId, secretNames }, ctx) => {
				const did = callerDid(ctx);
				if (!did) throw new Error("UNAUTHENTICATED");
				const ciphertext = await getRedeemedKeyCiphertext(db, did);
				if (ciphertext === null) return {
					injected: false,
					secretNames: []
				};
				const apiKey = await transit.decrypt(ACCESS_CODES_TRANSIT_TENANT, ciphertext);
				for (const name of secretNames) await secretsService.setSecret(tenantId, name, apiKey);
				await secretsService.setSecret(tenantId, "VETRA_SESSION_EXPORT_SECRET", randomBytes(32).toString("hex"));
				return {
					injected: true,
					secretNames
				};
			},
			createInviteCode: async (_p, args, ctx) => {
				requireAdmin(ctx);
				const anthropicKeyCiphertext = args.anthropicApiKey ? await encryptAttachedKey(args.anthropicApiKey) : null;
				return createCode(db, {
					code: args.code,
					label: args.label ?? null,
					expiresAt: args.expiresAt ?? null,
					maxUses: args.maxUses ?? null,
					anthropicKeyCiphertext
				});
			},
			setInviteCodeAnthropicKey: async (_p, { code, anthropicApiKey }, ctx) => {
				requireAdmin(ctx);
				const view = await setCodeAnthropicKey(db, code, anthropicApiKey ? await encryptAttachedKey(anthropicApiKey) : null);
				if (!view) throw new Error("CODE_NOT_FOUND");
				return view;
			},
			setInviteCodeActive: async (_p, { code, active }, ctx) => {
				requireAdmin(ctx);
				const view = await setActiveCode(db, code, active);
				if (!view) throw new Error("CODE_NOT_FOUND");
				return view;
			},
			revokeAccess: (_p, { address }, ctx) => {
				requireAdmin(ctx);
				return revokeAccess(db, address);
			}
		}
	};
}
//#endregion
export { createResolvers as n, getRedeemedKeyCiphertext as r, ACCESS_CODES_TRANSIT_TENANT as t };

//# sourceMappingURL=resolvers-BBYXs_2f.js.map