Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
admin.ts3.5 KB · 139 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
/**
 * Block F3 — Site admin helpers.
 *
 * A site admin is a user with a row in `site_admins`. If the table is empty,
 * the very first registered user is the bootstrap admin. This mirrors how
 * many self-hosted apps handle the first-install case without requiring
 * `env`-based provisioning.
 *
 * Writes to system flags go through `setFlag` which records the writer.
 */

import { asc, eq } from "drizzle-orm";
import { db } from "../db";
import { siteAdmins, systemFlags, users } from "../db/schema";

/**
 * Is this user a site admin? Returns true if any of:
 *  - they have a row in `site_admins`, OR
 *  - no rows exist in `site_admins` and they are the oldest-created user
 *    (bootstrap rule).
 */
export async function isSiteAdmin(
  userId: string | null | undefined
): Promise<boolean> {
  if (!userId) return false;
  try {
    const [row] = await db
      .select({ userId: siteAdmins.userId })
      .from(siteAdmins)
      .where(eq(siteAdmins.userId, userId))
      .limit(1);
    if (row) return true;
    // Bootstrap: empty site_admins → oldest user is admin.
    const [anyAdmin] = await db.select().from(siteAdmins).limit(1);
    if (anyAdmin) return false;
    const [first] = await db
      .select({ id: users.id })
      .from(users)
      .orderBy(asc(users.createdAt))
      .limit(1);
    return !!first && first.id === userId;
  } catch {
    return false;
  }
}

export async function listSiteAdmins() {
  try {
    return await db
      .select({
        userId: siteAdmins.userId,
        username: users.username,
        grantedAt: siteAdmins.grantedAt,
        grantedBy: siteAdmins.grantedBy,
      })
      .from(siteAdmins)
      .innerJoin(users, eq(siteAdmins.userId, users.id));
  } catch {
    return [];
  }
}

export async function grantSiteAdmin(
  userId: string,
  grantedBy: string | null
): Promise<boolean> {
  try {
    await db
      .insert(siteAdmins)
      .values({ userId, grantedBy: grantedBy || null })
      .onConflictDoNothing();
    return true;
  } catch {
    return false;
  }
}

export async function revokeSiteAdmin(userId: string): Promise<boolean> {
  try {
    const res = await db
      .delete(siteAdmins)
      .where(eq(siteAdmins.userId, userId))
      .returning({ userId: siteAdmins.userId });
    return res.length > 0;
  } catch {
    return false;
  }
}

export async function getFlag(key: string): Promise<string | null> {
  try {
    const [row] = await db
      .select({ value: systemFlags.value })
      .from(systemFlags)
      .where(eq(systemFlags.key, key))
      .limit(1);
    return row?.value ?? null;
  } catch {
    return null;
  }
}

export async function setFlag(
  key: string,
  value: string,
  updatedBy: string | null
): Promise<boolean> {
  try {
    await db
      .insert(systemFlags)
      .values({ key, value, updatedBy: updatedBy || null })
      .onConflictDoUpdate({
        target: systemFlags.key,
        set: { value, updatedBy: updatedBy || null, updatedAt: new Date() },
      });
    return true;
  } catch (err) {
    console.error("[admin] setFlag:", err);
    return false;
  }
}

export async function listFlags() {
  try {
    return await db.select().from(systemFlags);
  } catch {
    return [];
  }
}

/** Known flag keys with defaults (used by callers + UI rendering). */
export const KNOWN_FLAGS = {
  registration_locked: "0", // "1" to block new sign-ups
  site_banner_text: "", // non-empty → show a banner at the top
  site_banner_level: "info", // info | warn | error
  read_only_mode: "0",
} as const;

export type FlagKey = keyof typeof KNOWN_FLAGS;