feat(cloud-api): v0.2 OIDC login/callback (state, nonce, user upsert)
This commit is contained in:
@@ -184,6 +184,87 @@ test("workbench workspace is account-scoped, persistent, and permits independent
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /auth/oidc/login returns 302 to Keycloak when issuer and client are configured", async () => {
|
||||
const server = createCloudApiServer({
|
||||
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: "https://auth.74-48-78-17.nip.io/realms/hwlab", HWLAB_KEYCLOAK_CLIENT_ID: "hwlab-cloud-web", HWLAB_PUBLIC_ENDPOINT: "http://74.48.78.17:19667" },
|
||||
now: () => "2026-06-03T00:00:00.000Z"
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/auth/oidc/login?returnTo=/workbench`, { redirect: "manual" });
|
||||
assert.equal(response.status, 302);
|
||||
const location = response.headers.get("location");
|
||||
assert.ok(location?.startsWith("https://auth.74-48-78-17.nip.io/realms/hwlab/protocol/openid-connect/auth"), `unexpected location: ${location}`);
|
||||
assert.ok(location?.includes("client_id=hwlab-cloud-web"));
|
||||
assert.ok(location?.includes("response_type=code"));
|
||||
assert.ok(location?.includes("state="));
|
||||
assert.ok(location?.includes("nonce="));
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /auth/oidc/callback upserts user by issuer+sub and issues 24h web session", async () => {
|
||||
const accessController = createAccessController({
|
||||
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: "https://auth.74-48-78-17.nip.io/realms/hwlab", HWLAB_KEYCLOAK_CLIENT_ID: "hwlab-cloud-web", HWLAB_KEYCLOAK_CLIENT_SECRET: "secret", HWLAB_PUBLIC_ENDPOINT: "http://74.48.78.17:19667" },
|
||||
fetchImpl: makeOidcFetchMock(),
|
||||
now: () => "2026-06-03T00:00:00.000Z"
|
||||
});
|
||||
const state = "test-state";
|
||||
await accessController.store.upsertOidcState({ state, nonce: "test-nonce", returnTo: "/workbench", createdAt: "2026-06-03T00:00:00.000Z", expiresAt: "2026-06-03T00:10:00.000Z" });
|
||||
const server = createCloudApiServer({
|
||||
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
|
||||
accessController,
|
||||
now: () => "2026-06-03T00:00:00.000Z"
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/auth/oidc/callback?code=oidc-code&state=${state}`, { redirect: "manual" });
|
||||
assert.equal(response.status, 302);
|
||||
const cookie = response.headers.get("set-cookie") ?? "";
|
||||
assert.match(cookie, /hwlab_session=[A-Za-z0-9_-]+;/);
|
||||
assert.match(cookie, /HttpOnly/);
|
||||
assert.match(cookie, /Secure/);
|
||||
assert.match(cookie, /Max-Age=86400/);
|
||||
const sessionResponse = await fetch(`http://127.0.0.1:${port}/v1/auth/session`, { headers: { cookie: cookie.split(";")[0] } });
|
||||
assert.equal(sessionResponse.status, 200);
|
||||
const sessionBody = await sessionResponse.json();
|
||||
assert.equal(sessionBody.authenticated, true);
|
||||
assert.equal(sessionBody.actor.username, "oidc-user");
|
||||
assert.equal(sessionBody.actor.role, "user");
|
||||
assert.equal(sessionBody.authMethod, "web-session");
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
function makeOidcFetchMock() {
|
||||
return async (url) => {
|
||||
const u = new URL(String(url));
|
||||
if (u.pathname.endsWith("/protocol/openid-connect/token")) {
|
||||
return new Response(JSON.stringify({
|
||||
access_token: "oidc-access-token",
|
||||
refresh_token: "oidc-refresh-token",
|
||||
id_token: "oidc-id-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 300
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
if (u.pathname.endsWith("/protocol/openid-connect/userinfo")) {
|
||||
return new Response(JSON.stringify({
|
||||
sub: "keycloak-sub-1234",
|
||||
preferred_username: "oidc-user",
|
||||
email: "oidc-user@hwlab.local",
|
||||
name: "OIDC User",
|
||||
email_verified: true
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
}
|
||||
|
||||
test("workbench workspace permits a new turn after AgentRun active trace reaches terminal status", async () => {
|
||||
const agentRunCalls = [];
|
||||
const agentSessions = new Map();
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
syncAgentRunChatResult
|
||||
} from "./code-agent-agentrun-adapter.ts";
|
||||
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
|
||||
import { getHeader, readBody, sendJson, truthyFlag } from "./server-http-utils.ts";
|
||||
import { getHeader, readBody, sendJson, sendRedirect, truthyFlag } from "./server-http-utils.ts";
|
||||
|
||||
const SESSION_COOKIE = "hwlab_session";
|
||||
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24;
|
||||
@@ -83,6 +83,14 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id, status, created_at DESC)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix)`,
|
||||
`CREATE TABLE IF NOT EXISTS oidc_states (
|
||||
state TEXT PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
return_to TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
@@ -240,6 +248,14 @@ class AccessController {
|
||||
async handleAuthRoute(request, response, url) {
|
||||
try {
|
||||
await this.ensureBootstrap();
|
||||
if (url.pathname === "/auth/oidc/login" && (request.method === "GET" || request.method === "POST")) {
|
||||
await this.handleOidcLogin(request, response, url);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/auth/oidc/callback" && request.method === "GET") {
|
||||
await this.handleOidcCallback(request, response, url);
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/auth/session") {
|
||||
sendJson(response, 200, await this.sessionPayload(request));
|
||||
return;
|
||||
@@ -623,6 +639,158 @@ class AccessController {
|
||||
};
|
||||
}
|
||||
|
||||
async handleOidcLogin(request, response, url) {
|
||||
const issuer = textOr(this.env.HWLAB_KEYCLOAK_ISSUER, "");
|
||||
const clientId = textOr(this.env.HWLAB_KEYCLOAK_CLIENT_ID, "");
|
||||
if (!issuer || !clientId) {
|
||||
return sendJson(response, 503, errorPayload("oidc_not_configured", "OIDC issuer / client are not configured on this cloud-api", 503));
|
||||
}
|
||||
const returnTo = textOr(url.searchParams.get("returnTo"), "/");
|
||||
const state = randomBytes(24).toString("base64url");
|
||||
const nonce = randomBytes(24).toString("base64url");
|
||||
const authorizeUrl = new URL(`${issuer.replace(/\/$/, "")}/protocol/openid-connect/auth`);
|
||||
authorizeUrl.searchParams.set("response_type", "code");
|
||||
authorizeUrl.searchParams.set("client_id", clientId);
|
||||
authorizeUrl.searchParams.set("redirect_uri", this.oidcRedirectUri(request));
|
||||
authorizeUrl.searchParams.set("scope", "openid profile email");
|
||||
authorizeUrl.searchParams.set("state", state);
|
||||
authorizeUrl.searchParams.set("nonce", nonce);
|
||||
await this.store.upsertOidcState?.({
|
||||
state,
|
||||
nonce,
|
||||
returnTo,
|
||||
createdAt: this.now(),
|
||||
expiresAt: new Date(Date.parse(this.now()) + 600 * 1000).toISOString()
|
||||
});
|
||||
sendRedirect(response, authorizeUrl.toString(), { contractVersion: "oidc-v1" });
|
||||
}
|
||||
|
||||
async handleOidcCallback(request, response, url) {
|
||||
const issuer = textOr(this.env.HWLAB_KEYCLOAK_ISSUER, "");
|
||||
const clientId = textOr(this.env.HWLAB_KEYCLOAK_CLIENT_ID, "");
|
||||
const clientSecret = textOr(this.env.HWLAB_KEYCLOAK_CLIENT_SECRET, "");
|
||||
if (!issuer || !clientId || !clientSecret) {
|
||||
return sendJson(response, 503, errorPayload("oidc_not_configured", "OIDC issuer / client are not configured on this cloud-api", 503));
|
||||
}
|
||||
const code = textOr(url.searchParams.get("code"), "");
|
||||
const state = textOr(url.searchParams.get("state"), "");
|
||||
const error = textOr(url.searchParams.get("error"), "");
|
||||
if (error) {
|
||||
return sendJson(response, 400, errorPayload("oidc_error", `OIDC provider returned error: ${error}`, 400));
|
||||
}
|
||||
if (!code || !state) {
|
||||
return sendJson(response, 400, errorPayload("oidc_missing_params", "code and state are required", 400));
|
||||
}
|
||||
const stored = await this.store.consumeOidcState?.(state) ?? null;
|
||||
if (!stored) {
|
||||
return sendJson(response, 400, errorPayload("oidc_invalid_state", "OIDC state not found or already consumed", 400));
|
||||
}
|
||||
if (new Date(stored.expiresAt).getTime() < Date.parse(this.now())) {
|
||||
return sendJson(response, 400, errorPayload("oidc_state_expired", "OIDC state expired; retry login", 400));
|
||||
}
|
||||
const tokenResponse = await this.exchangeOidcCode({ issuer, clientId, clientSecret, code, request });
|
||||
if (!tokenResponse.ok) {
|
||||
return sendJson(response, tokenResponse.status, errorPayload(tokenResponse.code, tokenResponse.message, tokenResponse.status));
|
||||
}
|
||||
const userinfo = await this.fetchOidcUserinfo({ issuer, accessToken: tokenResponse.accessToken });
|
||||
if (!userinfo.ok) {
|
||||
return sendJson(response, userinfo.status, errorPayload(userinfo.code, userinfo.message, userinfo.status));
|
||||
}
|
||||
if (textOr(userinfo.claims.nonce, "") && textOr(userinfo.claims.nonce, "") !== textOr(stored.nonce, "")) {
|
||||
return sendJson(response, 400, errorPayload("oidc_nonce_mismatch", "OIDC nonce mismatch", 400));
|
||||
}
|
||||
const actor = await this.upsertOidcUser({ issuer, claims: userinfo.claims, accessToken: tokenResponse.accessToken, refreshToken: tokenResponse.refreshToken });
|
||||
if (!actor) {
|
||||
return sendJson(response, 500, errorPayload("oidc_user_upsert_failed", "Failed to upsert OIDC user", 500));
|
||||
}
|
||||
const session = await this.issueWebSessionForActor(actor);
|
||||
setSessionCookie(response, session.token, SESSION_MAX_AGE_SECONDS);
|
||||
sendRedirect(response, stored.returnTo || "/", { contractVersion: "oidc-v1" });
|
||||
}
|
||||
|
||||
oidcRedirectUri(request) {
|
||||
const publicBase = textOr(this.env.HWLAB_PUBLIC_ENDPOINT, "");
|
||||
if (publicBase) return `${publicBase.replace(/\/$/, "")}/auth/oidc/callback`;
|
||||
return `${textOr(this.env.HWLAB_RUNTIME_API_URL, "http://127.0.0.1:6667")}/auth/oidc/callback`;
|
||||
}
|
||||
|
||||
async exchangeOidcCode({ issuer, clientId, clientSecret, code, request }) {
|
||||
const tokenUrl = `${issuer.replace(/\/$/, "")}/protocol/openid-connect/token`;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: this.oidcRedirectUri(request)
|
||||
});
|
||||
try {
|
||||
const response = await this.fetchImpl(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString()
|
||||
});
|
||||
const text = await response.text();
|
||||
const json = parseJson(text, {});
|
||||
if (response.status >= 400 || !json.access_token) {
|
||||
return { ok: false, status: response.status, code: "oidc_token_exchange_failed", message: textOr(json.error_description, text) };
|
||||
}
|
||||
return { ok: true, accessToken: json.access_token, refreshToken: json.refresh_token ?? null, idTokenClaims: json };
|
||||
} catch (error) {
|
||||
return { ok: false, status: 500, code: "oidc_token_exchange_error", message: error?.message ?? String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async fetchOidcUserinfo({ issuer, accessToken }) {
|
||||
try {
|
||||
const userinfoUrl = `${issuer.replace(/\/$/, "")}/protocol/openid-connect/userinfo`;
|
||||
const response = await this.fetchImpl(userinfoUrl, { headers: { accept: "application/json", authorization: `Bearer ${accessToken}` } });
|
||||
const text = await response.text();
|
||||
const claims = parseJson(text, {});
|
||||
if (response.status >= 400 || !claims.sub) {
|
||||
return { ok: false, status: response.status, code: "oidc_userinfo_failed", message: textOr(claims.error_description, text) };
|
||||
}
|
||||
return { ok: true, claims };
|
||||
} catch (error) {
|
||||
return { ok: false, status: 500, code: "oidc_userinfo_error", message: error?.message ?? String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async upsertOidcUser({ issuer, claims }) {
|
||||
const sub = textOr(claims.sub, "");
|
||||
if (!sub) return null;
|
||||
const username = textOr(claims.preferred_username, claims.email ?? sub);
|
||||
const email = textOr(claims.email, "");
|
||||
const displayName = textOr(claims.name, username);
|
||||
const existing = await this.store.findUserByKeycloakSubject?.(issuer, sub) ?? null;
|
||||
if (existing) {
|
||||
await this.store.updateUserOidcLogin?.({ userId: existing.id, email, lastLoginAt: this.now() });
|
||||
return existing;
|
||||
}
|
||||
return await this.store.createUserOidc?.({
|
||||
username,
|
||||
displayName,
|
||||
email,
|
||||
keycloakIssuer: issuer,
|
||||
keycloakSub: sub,
|
||||
role: "user",
|
||||
status: "active",
|
||||
now: this.now()
|
||||
});
|
||||
}
|
||||
|
||||
async issueWebSessionForActor(actor) {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const now = this.now();
|
||||
const session = await this.store.createSession({
|
||||
userId: actor.id,
|
||||
tokenHash: sha256(token),
|
||||
now,
|
||||
expiresAt: new Date(Date.parse(now) + SESSION_MAX_AGE_SECONDS * 1000).toISOString()
|
||||
});
|
||||
await this.store.ensureDefaultApiKeyForUser?.({ userId: actor.id, now });
|
||||
return { token, session };
|
||||
}
|
||||
|
||||
async handleLogin(request, response) {
|
||||
const body = await jsonBody(request);
|
||||
const user = await this.store.findUserByUsername(requiredText(body.username, "username"));
|
||||
@@ -1562,6 +1730,7 @@ class MemoryAccessStore {
|
||||
this.users = new Map();
|
||||
this.sessions = new Map();
|
||||
this.apiKeys = new Map();
|
||||
this.oidcStates = new Map();
|
||||
this.devicePods = new Map();
|
||||
this.grants = new Set();
|
||||
this.jobs = new Map();
|
||||
@@ -1616,6 +1785,11 @@ class MemoryAccessStore {
|
||||
async touchApiKey(id, now) { const key = this.apiKeys.get(id); if (key) key.lastUsedAt = now; }
|
||||
async revokeApiKey(id, now) { const key = this.apiKeys.get(id); if (key) { key.status = "revoked"; key.revokedAt = now; } return key ?? null; }
|
||||
async revokeApiKeysForUser(userId, now) { let count = 0; for (const key of this.apiKeys.values()) { if (key.userId === userId && key.status === "active") { key.status = "revoked"; key.revokedAt = now; count += 1; } } return count; }
|
||||
async upsertOidcState(input) { this.oidcStates.set(input.state, { state: input.state, nonce: input.nonce, returnTo: input.returnTo, createdAt: input.createdAt, expiresAt: input.expiresAt }); return { ok: true }; }
|
||||
async consumeOidcState(state) { const entry = this.oidcStates.get(state) ?? null; if (entry) this.oidcStates.delete(state); return entry; }
|
||||
async findUserByKeycloakSubject(issuer, sub) { for (const user of this.users.values()) { if (user.authProvider === "keycloak" && user.keycloakIssuer === issuer && user.keycloakSub === sub) return user; } return null; }
|
||||
async createUserOidc(input) { const id = `usr_${randomUUID()}`; const now = input.now ?? this.now(); const user = { id, username: input.username, displayName: input.displayName ?? input.username, role: input.role ?? "user", status: input.status ?? "active", passwordHash: null, authProvider: "keycloak", keycloakIssuer: input.keycloakIssuer, keycloakSub: input.keycloakSub, email: input.email, lastLoginAt: now, createdAt: now, updatedAt: now }; this.users.set(id, user); return user; }
|
||||
async updateUserOidcLogin(input) { const user = this.users.get(input.userId); if (!user) return null; this.users.set(input.userId, { ...user, email: input.email, lastLoginAt: input.lastLoginAt, updatedAt: this.now() }); return this.users.get(input.userId); }
|
||||
async ensureDefaultApiKeyForUser(input = {}) {
|
||||
const existing = await this.findActiveDefaultApiKeyForUser(input.userId);
|
||||
if (existing) return { key: existing, created: false };
|
||||
@@ -1770,6 +1944,11 @@ class PostgresAccessStore extends MemoryAccessStore {
|
||||
async touchApiKey(id, now) { await this.ensureSchema(); await this.query("UPDATE api_keys SET last_used_at = $2 WHERE id = $1", [id, now]); }
|
||||
async revokeApiKey(id, now) { await this.ensureSchema(); const result = await this.query("UPDATE api_keys SET status = 'revoked', revoked_at = $2 WHERE id = $1 RETURNING id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at", [id, now]); return pgApiKey(result.rows?.[0]); }
|
||||
async revokeApiKeysForUser(userId, now) { await this.ensureSchema(); const result = await this.query("UPDATE api_keys SET status = 'revoked', revoked_at = $2 WHERE user_id = $1 AND status = 'active' RETURNING id", [userId, now]); return result.rows?.length ?? 0; }
|
||||
async upsertOidcState(input) { await this.ensureSchema(); await this.query("INSERT INTO oidc_states (state, nonce, return_to, created_at, expires_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (state) DO UPDATE SET nonce = EXCLUDED.nonce, return_to = EXCLUDED.return_to, created_at = EXCLUDED.created_at, expires_at = EXCLUDED.expires_at", [input.state, input.nonce, input.returnTo ?? "", input.createdAt, input.expiresAt]); return { ok: true }; }
|
||||
async consumeOidcState(state) { await this.ensureSchema(); const result = await this.query("DELETE FROM oidc_states WHERE state = $1 RETURNING state, nonce, return_to, created_at, expires_at", [state]); const row = result.rows?.[0]; if (!row) return null; return { state: row.state, nonce: row.nonce, returnTo: row.return_to, createdAt: row.created_at, expiresAt: row.expires_at }; }
|
||||
async findUserByKeycloakSubject(issuer, sub) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at FROM users WHERE keycloak_issuer = $1 AND keycloak_sub = $2 LIMIT 1", [issuer, sub]); return pgUserWithOIDC(result.rows?.[0]); }
|
||||
async createUserOidc(input) { await this.ensureSchema(); const id = `usr_${randomUUID()}`; const now = input.now ?? this.now(); const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (username) DO UPDATE SET display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, auth_provider = EXCLUDED.auth_provider, keycloak_issuer = EXCLUDED.keycloak_issuer, keycloak_sub = EXCLUDED.keycloak_sub, email = EXCLUDED.email, last_login_at = EXCLUDED.last_login_at, updated_at = EXCLUDED.updated_at RETURNING id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at", [id, input.username, input.displayName ?? input.username, input.role ?? "user", input.status ?? "active", null, "keycloak", input.keycloakIssuer, input.keycloakSub, input.email ?? null, now, now, now]); return pgUserWithOIDC(result.rows?.[0]); }
|
||||
async updateUserOidcLogin(input) { await this.ensureSchema(); const result = await this.query("UPDATE users SET email = COALESCE($2, email), last_login_at = $3, updated_at = $3 WHERE id = $1 RETURNING id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at", [input.userId, input.email ?? null, input.lastLoginAt]); return pgUserWithOIDC(result.rows?.[0]); }
|
||||
async upsertDevicePod(input) { await this.ensureSchema(); const pod = await super.upsertDevicePod(input); const result = await this.query("INSERT INTO device_pods (id, name, status, profile_json, profile_hash, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, status = EXCLUDED.status, profile_json = EXCLUDED.profile_json, profile_hash = EXCLUDED.profile_hash, updated_at = EXCLUDED.updated_at RETURNING *", [pod.id, pod.name, pod.status, stableJson(pod.profile), pod.profileHash, pod.createdAt, pod.updatedAt]); return pgDevicePod(result.rows?.[0]); }
|
||||
async grantDevicePod(input) { await this.ensureSchema(); const grant = await super.grantDevicePod(input); await this.query("INSERT INTO device_pod_grants (device_pod_id, user_id, created_by_admin_id, created_at) VALUES ($1,$2,$3,$4) ON CONFLICT (device_pod_id, user_id) DO UPDATE SET created_by_admin_id = EXCLUDED.created_by_admin_id, created_at = EXCLUDED.created_at", [grant.devicePodId, grant.userId, grant.createdByAdminId, grant.createdAt]); return grant; }
|
||||
async revokeDevicePodGrant(input) { await this.ensureSchema(); await this.query("DELETE FROM device_pod_grants WHERE device_pod_id = $1 AND user_id = $2", [input.devicePodId, input.userId]); }
|
||||
|
||||
@@ -28,6 +28,15 @@ export function sendJson(response, statusCode, body) {
|
||||
response.end(JSON.stringify(body) + "\n");
|
||||
}
|
||||
|
||||
export function sendRedirect(response, location, body = {}) {
|
||||
response.writeHead(302, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
location
|
||||
});
|
||||
response.end(JSON.stringify({ redirectTo: location, ...body }) + "\n");
|
||||
}
|
||||
|
||||
export function getHeader(request, name) {
|
||||
const value = request.headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) return value[0];
|
||||
|
||||
Reference in New Issue
Block a user