Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Builder Console keeps app metadata, access requests, comments, and sharing polic

## Repository

- `cmd/server/`: Go server and tests, using `github.com/appthrust/appserver-go v0.2.0`.
- `cmd/server/`: Go server and tests, using `github.com/appthrust/appserver-go v0.3.0`.
- `web/`: Vite/React SPA, unit tests, in-memory development APIs, and Playwright stories.
- `catalog/entry.yaml`: ApplicationStackDefinition, including the Directory dependency,
PostgreSQL connection, identity access settings, probes, and existing version floors.
Expand Down Expand Up @@ -104,6 +104,9 @@ and the `/api/*` Platform proxy retain their existing contracts.

## Publication

Next release: **v0.4.0**. Membership and app-creation permissions come from
Project IAM access; non-member notices and settings no longer name an owning Team.

Only a `v<semver>` tag starts publication. The initial standalone release is
**v0.3.0**, stream **0.3**, at `ghcr.io/appthrust/builder-console`. Do not reuse
`0.1.0` or `0.2.0`: both served Platform-image floors remain verbatim in the committed
Expand Down
8 changes: 2 additions & 6 deletions cmd/server/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ type request struct {
groups []domain.Group
effectiveGroupIDs []string
directory *directoryview.Session
access platform.TeamAccess
access platform.ProjectAccess
bearer string
platformUnavailable bool
}
Expand Down Expand Up @@ -200,11 +200,7 @@ func (a *API) withRequest(ctx context.Context, claims identity.Identity, bearer
}
}
if state.access.NotMember && !me {
message := "This Project belongs to a Team; ask its administrator to add you"
if state.access.TeamSlug != "" {
message = "This Project belongs to Team " + state.access.TeamSlug
}
return httpx.Fail(403, "not_project_member", message)
return httpx.Fail(403, "not_project_member", "You are not a member of this Project; ask its administrator to add you")
}
}
state.groups, err = a.directory.Groups(ctx, bearer)
Expand Down
22 changes: 8 additions & 14 deletions cmd/server/internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,12 @@ func newHarness(t *testing.T) *harness {
role = "Editor"
}
switch r.URL.Path {
case "/api/v1/projects/pj-test":
case "/api/v1/projects/pj-test/access":
if status := int(h.projectStatus.Load()); status != 0 {
w.WriteHeader(status)
return
}
httpx.WriteJSON(w, 200, map[string]string{"teamSlug": "test-team"})
case "/api/v1/teams":
httpx.WriteJSON(w, 200, map[string]any{"items": []any{map[string]string{"id": "team-id", "slug": "test-team", "role": role}}})
case "/api/v1/teams/team-id/members":
httpx.WriteJSON(w, 200, map[string]any{"items": []any{map[string]string{"id": "one", "email": "admin@example.test", "name": "Admin"}}})
case "/api/v1/teams/team-id/directory-users":
httpx.WriteJSON(w, 200, map[string]any{"items": []any{map[string]string{"id": "duplicate", "email": "admin@example.test", "name": "Admin"}, map[string]string{"id": "two", "email": "person@example.test", "name": "Person"}}})
httpx.WriteJSON(w, 200, map[string]any{"projectId": "pj-test", "member": true, "role": role, "platformRoles": []string{}})
case "/api/v1/projects/pj-test/source-connections":
httpx.WriteJSON(w, 200, map[string]any{"data": []any{map[string]string{"id": "pending", "status": "incomplete"}, map[string]string{"id": "github", "status": "ready"}}})
case "/api/v1/registry-connections":
Expand Down Expand Up @@ -198,7 +192,7 @@ func TestProjectNonMemberRoutesIntegration(t *testing.T) {
call[meResponse](h, "admin", "GET", "/me", nil, 200)
h.projectStatus.Store(int32(status))
me := call[meResponse](h, "outsider", "GET", "/me", nil, 200)
if !me.NotMember || me.TeamSlug != "test-team" || me.PlatformUnavailable ||
if !me.NotMember || me.PlatformUnavailable ||
me.CanCreateApps || me.IsConsoleAdmin || me.Person.ID != person.ID || len(me.EffectiveGroupIDs) != 0 {
t.Fatalf("non-member me leaked roles or memberships: %+v", me)
}
Expand All @@ -220,24 +214,24 @@ func TestProjectNonMemberRoutesIntegration(t *testing.T) {
{"GET", "/admin/platform"},
} {
failure := call[httpx.Error](h, "outsider", route.method, route.path, nil, 403)
if failure.Code != "not_project_member" || failure.Message != "This Project belongs to Team test-team" {
if failure.Code != "not_project_member" || failure.Message != "You are not a member of this Project; ask its administrator to add you" {
t.Fatalf("%s %s: %+v", route.method, route.path, failure)
}
}
})
}
}

func TestProjectNonMemberWithoutKnownTeamIntegration(t *testing.T) {
func TestProjectNonMemberBeforeFirstMemberLookupIntegration(t *testing.T) {
h := newHarness(t)
h.projectStatus.Store(http.StatusForbidden)
me := call[meResponse](h, "outsider", "GET", "/me", nil, 200)
if !me.NotMember || me.TeamSlug != "" || me.PlatformUnavailable || me.CanCreateApps || me.IsConsoleAdmin {
if !me.NotMember || me.PlatformUnavailable || me.CanCreateApps || me.IsConsoleAdmin {
t.Fatalf("non-member before successful Project lookup: %+v", me)
}
failure := call[httpx.Error](h, "outsider", "GET", "/apps", nil, 403)
if failure.Code != "not_project_member" || failure.Message != "This Project belongs to a Team; ask its administrator to add you" {
t.Fatalf("unknown owning Team: %+v", failure)
if failure.Code != "not_project_member" || failure.Message != "You are not a member of this Project; ask its administrator to add you" {
t.Fatalf("non-member notice: %+v", failure)
}
}

Expand Down
1 change: 0 additions & 1 deletion cmd/server/internal/api/directory_cutover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ func TestMeCompositionAndUnknownHydration(t *testing.T) {
require.True(t, me.CanCreateApps)
require.Equal(t, []string{group.ID}, me.EffectiveGroupIDs)
require.Equal(t, []string{group.ID}, me.ManagedGroupIDs)
require.Equal(t, "test-team", me.TeamSlug)
require.Equal(t, "pj-test", me.ProjectID)
require.Equal(t, "https://directory.example.test", me.DirectoryPublicURL)
require.False(t, me.Migration.Pending)
Expand Down
5 changes: 2 additions & 3 deletions cmd/server/internal/api/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ type meResponse struct {
ProjectID string `json:"projectId"`
PlatformUnavailable bool `json:"platformUnavailable,omitempty"`
NotMember bool `json:"notMember,omitempty"`
TeamSlug string `json:"teamSlug"`
DirectoryPublicURL string `json:"directoryPublicURL"`
Migration store.MigrationStatus `json:"migration"`
}
Expand All @@ -38,7 +37,7 @@ func (a *API) me(s *request) (any, int, error) {
if resolveErr != nil {
return nil, 0, resolveErr
}
return meResponse{Person: s.person, Roles: domain.Roles(s.access.Roles), EffectiveGroupIDs: effective, ManagedGroupIDs: managed, ProjectID: a.platform.ProjectID, PlatformUnavailable: s.platformUnavailable, NotMember: s.access.NotMember, TeamSlug: s.access.TeamSlug, DirectoryPublicURL: binding.PublicURL, Migration: migration}, 200, err
return meResponse{Person: s.person, Roles: domain.Roles(s.access.Roles), EffectiveGroupIDs: effective, ManagedGroupIDs: managed, ProjectID: a.platform.ProjectID, PlatformUnavailable: s.platformUnavailable, NotMember: s.access.NotMember, DirectoryPublicURL: binding.PublicURL, Migration: migration}, 200, err
}
func (a *API) migrateDirectory(s *request) (any, int, error) {
if !s.actor.IsConsoleAdmin {
Expand All @@ -61,7 +60,7 @@ func (a *API) migrateDirectory(s *request) (any, int, error) {
return result, 200, s.tx.SaveDirectoryMigration(result)
}
func (a *API) settings(s *request) (any, int, error) {
settings, err := a.platform.Settings(s.r.Context(), s.bearer, s.access)
settings, err := a.platform.Settings(s.r.Context(), s.bearer)
if err != nil {
return nil, 0, upstreamError()
}
Expand Down
6 changes: 2 additions & 4 deletions cmd/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -520,14 +520,12 @@ func TestServedOpenAPIEqualsCommittedContract(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/projects/pj-test":
case "/api/v1/projects/pj-test/access":
if !tc.member {
w.WriteHeader(http.StatusForbidden)
return
}
writeJSON(w, 200, map[string]string{"teamSlug": "test-team"})
case "/api/v1/teams":
writeJSON(w, 200, map[string]any{"items": []any{map[string]string{"id": "team-id", "slug": "test-team", "role": "Viewer"}}})
writeJSON(w, 200, map[string]any{"projectId": "pj-test", "member": true, "role": "Viewer", "platformRoles": []string{}})
default:
t.Errorf("unexpected platform route %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/appthrust/builder-console
go 1.26.0

require (
github.com/appthrust/appserver-go v0.2.0
github.com/appthrust/appserver-go v0.3.0
github.com/google/uuid v1.6.0
github.com/lib/pq v1.12.3
github.com/stretchr/testify v1.11.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
github.com/appthrust/appserver-go v0.2.0 h1:TLb4pzbTXy3uMER5vQAXdzeeOTLxuNYEUXXvTAVzWm8=
github.com/appthrust/appserver-go v0.2.0/go.mod h1:0KSf9RQZ6OxBcE0lxYzzjKF37nfwbKps3wXUGlE0oFo=
github.com/appthrust/appserver-go v0.3.0 h1:zxOz0hkspZSXjLq3KWGDLkZIz42Jag6wqRujZ8lTru0=
github.com/appthrust/appserver-go v0.3.0/go.mod h1:0KSf9RQZ6OxBcE0lxYzzjKF37nfwbKps3wXUGlE0oFo=
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
Expand Down
17 changes: 5 additions & 12 deletions web/e2e/stories/project-non-member.mock.spec.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { test, expect, seed } from '../support/fixtures'

test('explains Project non-membership with its Team, hides app actions, and lets the user log out @mock @story', async ({ page, request, mock }) => {
const teamSlug = 'owning-project-team'
test('explains Project non-membership, hides app actions, and lets the user log out @mock @story', async ({ page, request, mock }) => {
const title = 'この基盤(Project)のメンバーではありません'
const body = `この基盤は Team「${teamSlug}」が管理しています。管理者に追加を依頼してください。`
await mock.console('not-member', { teamSlug })
const body = 'この基盤の管理者に追加を依頼してください。'
await mock.console('not-member', {})

const me = await request.get('/api/console/me')
expect(me.status()).toBe(200)
expect(await me.json()).toMatchObject({
notMember: true, teamSlug, isConsoleAdmin: false, canCreateApps: false,
notMember: true, isConsoleAdmin: false, canCreateApps: false,
})
const apps = await request.get('/api/console/apps')
expect(apps.status()).toBe(403)
expect(await apps.json()).toEqual({ error: 'not_project_member', message: `This Project belongs to Team ${teamSlug}` })
expect(await apps.json()).toEqual({ error: 'not_project_member', message: 'You are not a member of this Project; ask its administrator to add you' })

await page.goto('/')
const main = page.getByRole('main')
Expand All @@ -36,12 +35,6 @@ test('explains Project non-membership with its Team, hides app actions, and lets
await expect(main.getByText(body, { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'メニューを開く', exact: true })).toHaveCount(0)

// Before the server has read the Project successfully, it must not invent a Team name.
await mock.console('not-member', { teamSlug: '' })
await page.reload()
await expect(main.getByText('管理者に追加を依頼してください。', { exact: true })).toBeVisible()
await expect(main.getByText(body, { exact: true })).toHaveCount(0)

// The edge owns logout; prove this stays a full document navigation, not an API call.
await page.route('**/logout', (route) => route.fulfill({ contentType: 'text/html', body: '<p>Signed out</p>' }))
await page.getByRole('button', { name: 'ユーザーメニュー', exact: true }).click()
Expand Down
3 changes: 0 additions & 3 deletions web/src/api/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,6 @@ export type Me = {
platformUnavailable?: boolean
/** The Project rejected this account; this is not a Platform outage. */
notMember?: boolean
/** Owning Team from the latest successful Project read; empty until one succeeds. */
teamSlug: string
/** Public origin of the Directory built-in app (profiles, group management, avatars); empty when not linked. */
directoryPublicURL: string
migration: DirectoryMigration
Expand All @@ -132,7 +130,6 @@ export type Resolved = { kind: 'group' | 'app' | 'entry'; id: string; currentPat
export type DirectoryUser = { id: string; email: string; displayName: string }
export type PlatformSettings = {
projectId: string
teamSlug: string | null
github: { connected: boolean; connectionId: string | null }
registry: { connected: boolean; connectionId: string | null }
defaultTemplateId: string
Expand Down
6 changes: 1 addition & 5 deletions web/src/copy/shell.en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@ export const shellEn: typeof shell = {
platformUnavailable: 'App creation is unavailable',
platformUnavailableBody: 'Wait a moment, then reload the page.',
notMemberTitle: 'You are not a project member',
notMemberBody: (teamSlug?: string) => teamSlug
? `This project is managed by the team “${teamSlug}”. Ask an admin to add you.`
: 'Ask an admin to add you.',
notMemberBody: 'Ask a project admin to add you.',
},
home: {
title: 'Home',
Expand Down Expand Up @@ -142,8 +140,6 @@ export const shellEn: typeof shell = {
registryOff: 'Not connected yet. Ask your workspace admin to set this up.',
team: 'App creators',
teamBody: 'People with app creation access can create apps here. Ask your workspace admin to add someone.',
teamName: 'Team',
teamUnknown: 'Unavailable',
project: 'Project',
advanced: 'Advanced',
connected: 'Connected',
Expand Down
6 changes: 1 addition & 5 deletions web/src/copy/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,7 @@ export const shell = {
platformUnavailable: 'いまアプリの作成状態を確認できません',
platformUnavailableBody: 'しばらくしてからページを再読み込みしてください。',
notMemberTitle: 'この基盤(Project)のメンバーではありません',
notMemberBody: (teamSlug?: string) => teamSlug
? `この基盤は Team「${teamSlug}」が管理しています。管理者に追加を依頼してください。`
: '管理者に追加を依頼してください。',
notMemberBody: 'この基盤の管理者に追加を依頼してください。',
},
home: {
title: 'ホーム',
Expand Down Expand Up @@ -143,8 +141,6 @@ export const shell = {
registryOff: 'まだ連携していません。基盤の担当者に設定を依頼してください。',
team: '作れる人',
teamBody: 'アプリを作れるのは、基盤で「作れる人」に登録された人です。追加は基盤の担当者が行います。',
teamName: 'チーム',
teamUnknown: '確認できません',
project: 'プロジェクト',
advanced: '詳しい人向け',
connected: '連携済み',
Expand Down
4 changes: 2 additions & 2 deletions web/src/dev/mock-console.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ test('platform reset restores seeded stacks, lifecycle state, toggles and the ID
`${project}/applications/nippo/branches/production/components/nippo-web/env-vars`,
`${project}/application-templates`,
`${project}/source-connections/github-source-207cb7ce/repositories`,
'/api/v1/teams',
`${project}/access`,
'/api/v1/registry-connections',
`${project}/application-drafts/creation-tools`,
]
Expand Down Expand Up @@ -125,7 +125,7 @@ test('platform reset restores seeded stacks, lifecycle state, toggles and the ID
process.env.MOCK_REGISTRY = 'off'
process.env.MOCK_CODEX = 'off'
expect((await call('POST', `${mock}/reset`)).status).toBe(200)
expect((await call('GET', '/api/v1/teams')).body.items[0].role).toBe('Viewer')
expect((await call('GET', `${project}/access`)).body).toEqual({ projectId: 'commerce', member: true, role: 'Viewer', platformRoles: [] })
expect((await call('GET', '/api/v1/registry-connections')).body.data).toEqual([])
expect((await call('GET', `${project}/application-drafts/creation-tools`)).body.codexAvailable).toBe(false)
} finally {
Expand Down
14 changes: 4 additions & 10 deletions web/src/dev/mock-console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// enough to exercise the UI; the real rules live in cmd/server.
// `POST /api/console/__mock/bare-500 {times?}` makes the next writes answer a
// bare gateway 500 (no JSON envelope), as during a console rollout.
// `POST /api/console/__mock/not-member {notMember?, teamSlug?}` denies Project
// `POST /api/console/__mock/not-member {notMember?}` denies Project
// access (default true); /me still answers with the non-member notice metadata.
// `POST /api/console/__mock/caller {personId, role?}` switches signed-in people
// (default role Viewer) for requester/approver stories; reset restores Yamada.
Expand Down Expand Up @@ -208,7 +208,6 @@ function seedConsole() {
role: (process.env.MOCK_ROLE as Role | undefined) ?? 'Admin',
platformUnavailable: process.env.MOCK_PLATFORM_UNAVAILABLE === '1',
notMember: false,
teamSlug: 'commerce-team',
projectId: process.env.MOCK_PROJECT_ID ?? 'commerce',
people,
directoryOnly,
Expand Down Expand Up @@ -368,7 +367,6 @@ function me(): Me {
isConsoleAdmin: isAdmin(),
canCreateApps: !state.notMember && !state.platformUnavailable && (state.role === 'Admin' || state.role === 'Editor'),
projectId: state.projectId,
teamSlug: state.teamSlug,
directoryPublicURL: requestOrigin,
migration: state.migration,
...(state.notMember ? { notMember: true } : {}),
Expand Down Expand Up @@ -560,10 +558,9 @@ export async function consoleRoutes(request: Request, url: URL): Promise<Respons
return json({ appId: input.appId, state: input.state })
}
if (head === '__mock' && id === 'not-member' && method === 'POST') {
const input = await body<{ notMember?: boolean; teamSlug?: string }>(request)
const input = await body<{ notMember?: boolean }>(request)
state.notMember = input.notMember ?? true
state.teamSlug = input.teamSlug ?? state.teamSlug
return json({ notMember: state.notMember, teamSlug: state.teamSlug })
return json({ notMember: state.notMember })
}
// Old console drafts (stackId null): the abandoned 作りかけ row.
if (head === '__mock' && id === 'app-draft' && method === 'POST') {
Expand Down Expand Up @@ -606,9 +603,7 @@ export async function consoleRoutes(request: Request, url: URL): Promise<Respons
if (head === 'directory') return directoryRoutes(request, url, segments.slice(1))
const needsRoles = head === 'resolve' || head === 'apps' || head === 'admin' || (head === 'access-requests' && !id && method === 'GET')
if (state.notMember && needsRoles) {
return fail(403, 'not_project_member', state.teamSlug
? `This Project belongs to Team ${state.teamSlug}`
: 'This Project belongs to a Team; ask its administrator to add you')
return fail(403, 'not_project_member', 'You are not a member of this Project; ask its administrator to add you')
}
if (head === 'resolve' && method === 'GET') {
const resolved = resolve(url.searchParams.get('path') ?? '')
Expand Down Expand Up @@ -920,7 +915,6 @@ export async function consoleRoutes(request: Request, url: URL): Promise<Respons
if (head === 'admin' && id === 'platform' && method === 'GET') {
return json({
projectId: state.projectId,
teamSlug: 'commerce-team',
github: { connected: true, connectionId: 'github-source-207cb7ce' },
registry: { connected: true, connectionId: 'registry-ghcr' },
defaultTemplateId: 'nextjs-postgres',
Expand Down
Loading