Skip to content

Commit b1e5250

Browse files
JamesReateclaude
andauthored
feat: typed shared-operations endpoint, an enum never calldata (plan 06 step 3) (#77)
POST /v1/tenants/{id}/vehicles/{tokenId}/shared-ops runs one of FOUR named operations — transfer_vehicle, burn_synthetic, burn_vehicle, grant_sacd — signed with the tenant's signer on the owner's kernel. The body cannot carry calldata and SharedOpArgs has no bytes field to smuggle it through: a general "sign this" endpoint would be a signing oracle over every kernel account any operator's signer can act for, and a narrow interface cannot be narrowed later, so it starts narrow. The op/field matrix is validated strictly in one place (SharedOpArgs.Validate) and runs twice: at the endpoint for the 400, and in the worker as the last point before calldata is built. Every job reuses ShareAuthorizer.AuthorizeShare unchanged and re-runs it at execution time, MaxAttempts 1 — sharper here than for shares, because a retried burn is not idempotent in any useful sense. Status mirrors ShareStatus exactly (same shape, tenant-scoped, not-found for another tenant's job) plus a job-kind check, which also had to be added to the share Status: the two surfaces draw from one queue and one id sequence, and SharedOpArgs decodes into ShareArgs cleanly enough (tenantId overlaps) that without the check each endpoint would serve the other's jobs. Vacuous while the queue held one kind; load-bearing now that it holds two. transfer_vehicle chains the post-transfer re-share — FullPermissions to the EFFECTIVE credential's client id, indefinite, on the NEW owner's kernel — inside the same job, porting kaufmann's shareWithTenant whole: one unit, one signer resolution, best-effort like the original (a landed transfer recorded as failed is exactly the chain/bookkeeping disagreement step 4 warns about). An unsignable target (external wallet) skips the re-share; a worker-time owner==target race skips the transfer leg and converges on the re-share. grant_sacd standalone is the same grant on the current owner, failures failing the job — the recovery op for a lost chained re-share. The transfer timeout is 15m (two receipt windows, under the 20m rescue); its worst case sits above kaufmann's 10-minute transfer poll, for step 4 to resolve before writing its loop. No per-member capability check, deliberately: the expected caller is kaufmann's workers, which gated the human at their own HTTP boundary and carry no session wallet — the invitations BFF split, and re-checking here would be an execution-time re-read of a request-time property. What IS enforced here is everything that must be true now: caller scope, entitlement, live ownership, live signer authority. burn_synthetic takes the synthetic token id from the caller rather than the roster: kaufmann's vins row is the live record, the roster a nightly reconcile that could refuse a fresh truth, and the chain bounds a wrong value to what the owner's kernel may burn anyway. SYNTHETIC_NFT_ADDRESS joins settings, both chart values files (kept in step) and the sharing all-or-nothing set: half-configured, a synthetic burn would be aimed at the zero address. Its rationale comment lives in templates/ — the version-bump workflow strips values comments. Claude-Session: https://claude.ai/code/session_01WSYDqdJ1fWmYJAXNgAJzTr Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 4ff3017 commit b1e5250

21 files changed

Lines changed: 1788 additions & 43 deletions

‎charts/fleet-tenancy-api/templates/secret.yaml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ spec:
6565
# alongside SACD_ADDRESS: RPC_URL is the chain node (eg. Alchemy) and
6666
# BUNDLER_URL is the ZeroDev project URL, which doubles as the paymaster URL.
6767
#
68+
# The values files also carry SYNTHETIC_NFT_ADDRESS (the SyntheticDeviceId
69+
# contract, target of the burn_synthetic shared operation — plan 06 step 3),
70+
# whose rationale lives here because the version-bump workflow strips
71+
# comments from values files. It is a public contract address, not a secret;
72+
# the prod value matches kaufmann-oracle's, which runs the same burn today.
73+
# Settings.Validate treats it as part of the sharing all-or-nothing set, so
74+
# removing it from values would refuse to boot rather than aim a burn at the
75+
# zero address.
76+
#
6877
# These land BEFORE the code that needs them, and that ordering is the point
6978
# of this being its own change: merging to main syncs the ExternalSecret,
7079
# while the share endpoint only ships on a later v* tag. Reversed, the

‎charts/fleet-tenancy-api/values-prod.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ env:
4141
INVITE_ACCEPT_URL_BASE: https://fleets.dimo.co
4242
CHAIN_ID: '137'
4343
SACD_ADDRESS: '0x3c152B5d96769661008Ff404224d6530FCAC766d'
44+
SYNTHETIC_NFT_ADDRESS: '0x4804e8D1661cd1a1e5dDdE1ff458A7f878c0aC6D'
4445
ingress:
4546
enabled: true
4647
className: nginx

‎charts/fleet-tenancy-api/values.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ env:
4141
ATTEST_API_URL: https://attest.dimo.zone
4242
CHAIN_ID: '137'
4343
SACD_ADDRESS: '0x3c152B5d96769661008Ff404224d6530FCAC766d'
44+
SYNTHETIC_NFT_ADDRESS: '0x4804e8D1661cd1a1e5dDdE1ff458A7f878c0aC6D'
4445
ingress:
4546
enabled: false
4647
className: nginx

‎cmd/fleet-tenancy-api/main.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,5 +197,12 @@ func shareWorkers(ctx context.Context, logger *zerolog.Logger, settings *config.
197197
if err := river.AddWorkerSafely(workers, sharing.NewShareWorker(logger, settings, authorizer, fleetClient)); err != nil {
198198
logger.Fatal().Err(err).Msg("failed to register the vehicle-share worker")
199199
}
200+
// The typed shared-operations worker (plan 06 step 3) shares the queue,
201+
// the fleet client and the authorizer with the share worker — its extra
202+
// dependency is the signer gate, which the chained post-transfer re-share
203+
// needs against the NEW owner, whom AuthorizeShare never saw.
204+
if err := river.AddWorkerSafely(workers, sharing.NewSharedOpWorker(logger, settings, authorizer, signerSvc, fleetClient)); err != nil {
205+
logger.Fatal().Err(err).Msg("failed to register the shared-operations worker")
206+
}
200207
return workers
201208
}

‎internal/app/app.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,14 @@ func App(settings *config.Settings, logger *zerolog.Logger, commitHash string, p
264264
// job id alone is a sequential integer anyone could walk.
265265
v1.Post("/tenants/:tenantId/vehicles/:tokenId/share", sharingCtrl.ShareVehicle)
266266
v1.Get("/tenants/:tenantId/vehicles/:tokenId/share/status", sharingCtrl.ShareStatus)
267+
// Typed shared-account operations (plan 06 step 3): one of four named ops
268+
// — transfer_vehicle, burn_synthetic, burn_vehicle, grant_sacd — signed
269+
// with the tenant's signer on the owner's kernel. The body carries an
270+
// enum, never calldata; the narrowness is the security boundary. Status
271+
// mirrors the share status above: same shape, same tenant-scoped
272+
// not-found, same reasoning about sequential job ids.
273+
v1.Post("/tenants/:tenantId/vehicles/:tokenId/shared-ops", sharingCtrl.SharedOperation)
274+
v1.Get("/tenants/:tenantId/vehicles/:tokenId/shared-ops/status", sharingCtrl.SharedOperationStatus)
267275

268276
// What the vehicles in a resolved set ARE — owner, definition, VIN, plate —
269277
// read from the roster this service reconciles against the chain nightly.

‎internal/app/sharing_routes_test.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,20 @@ func TestShareRoutesAreRegisteredAndGuarded(t *testing.T) {
7777
}
7878
v1.Post("/tenants/:tenantId/vehicles/:tokenId/share", handler)
7979
v1.Get("/tenants/:tenantId/vehicles/:tokenId/share/status", handler)
80+
// The typed shared-operations surface (plan 06 step 3) sits in the same
81+
// boat, more so: transfer and burn are both irreversible, and burn is
82+
// irreversible in a way even a grant is not.
83+
v1.Post("/tenants/:tenantId/vehicles/:tokenId/shared-ops", handler)
84+
v1.Get("/tenants/:tenantId/vehicles/:tokenId/shared-ops/status", handler)
8085

8186
const base = "/v1/tenants/aaaaaaaa-0000-0000-0000-000000000001/vehicles/42/share"
87+
const opsBase = "/v1/tenants/aaaaaaaa-0000-0000-0000-000000000001/vehicles/42/shared-ops"
8288

8389
for _, tc := range []struct{ name, method, path string }{
8490
{"share", http.MethodPost, base},
8591
{"status", http.MethodGet, base + "/status?jobId=1"},
92+
{"shared-ops", http.MethodPost, opsBase},
93+
{"shared-ops status", http.MethodGet, opsBase + "/status?jobId=1"},
8694
} {
8795
t.Run(tc.name+" without a key is refused before the handler", func(t *testing.T) {
8896
reached = false

‎internal/config/settings.go‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,18 @@ type Settings struct {
8080
// feature would look configured to an operator reading the chart and be
8181
// silently off to SharingConfigured, or worse, on with an address pointing
8282
// at the wrong contract. All or nothing is the only state worth booting.
83-
SacdAddress string `yaml:"SACD_ADDRESS"`
84-
RPCURL url.URL `yaml:"RPC_URL"` // secret
85-
BundlerURL url.URL `yaml:"BUNDLER_URL"` // secret
83+
//
84+
// SyntheticNftAddress is the SyntheticDeviceId NFT contract, the target of
85+
// the burn_synthetic shared operation (plan 06 step 3). A fourth address to
86+
// keep apart from the three above; the Polygon prod value matches
87+
// kaufmann-oracle's SYNTHETIC_NFT_ADDRESS, which runs the same burn today.
88+
// It joins the all-or-nothing set below because its absence is the
89+
// dangerous kind: half-configured, a synthetic-device burn would be aimed
90+
// at the zero address.
91+
SacdAddress string `yaml:"SACD_ADDRESS"`
92+
SyntheticNftAddress string `yaml:"SYNTHETIC_NFT_ADDRESS"`
93+
RPCURL url.URL `yaml:"RPC_URL"` // secret
94+
BundlerURL url.URL `yaml:"BUNDLER_URL"` // secret
8695

8796
// TrustedCallerKeys is the pre-shared key set that gates /v1, formatted
8897
// "name:key,name:key". The name is for logging and revocation only; it is
@@ -154,6 +163,7 @@ func (s *Settings) IsLocal() bool {
154163
// a nil-pointer panic in an unconfigured environment.
155164
func (s *Settings) SharingConfigured() bool {
156165
return s.SacdAddress != "" &&
166+
s.SyntheticNftAddress != "" &&
157167
s.VehicleNftAddress != "" &&
158168
s.RPCURL.String() != "" &&
159169
s.BundlerURL.String() != "" &&
@@ -209,15 +219,16 @@ func (s *Settings) Validate() error {
209219
// or aimed at the zero address fails in ways that read as a permissions bug.
210220
func (s *Settings) validateSharing() error {
211221
present := map[string]bool{
212-
"SACD_ADDRESS": s.SacdAddress != "",
213-
"RPC_URL": s.RPCURL.String() != "",
214-
"BUNDLER_URL": s.BundlerURL.String() != "",
215-
"VEHICLE_NFT_ADDRESS": s.VehicleNftAddress != "",
216-
"CHAIN_ID": s.ChainID != 0,
222+
"SACD_ADDRESS": s.SacdAddress != "",
223+
"SYNTHETIC_NFT_ADDRESS": s.SyntheticNftAddress != "",
224+
"RPC_URL": s.RPCURL.String() != "",
225+
"BUNDLER_URL": s.BundlerURL.String() != "",
226+
"VEHICLE_NFT_ADDRESS": s.VehicleNftAddress != "",
227+
"CHAIN_ID": s.ChainID != 0,
217228
}
218229
missing := []string{}
219230
any := false
220-
for _, name := range []string{"SACD_ADDRESS", "RPC_URL", "BUNDLER_URL", "VEHICLE_NFT_ADDRESS", "CHAIN_ID"} {
231+
for _, name := range []string{"SACD_ADDRESS", "SYNTHETIC_NFT_ADDRESS", "RPC_URL", "BUNDLER_URL", "VEHICLE_NFT_ADDRESS", "CHAIN_ID"} {
221232
if present[name] {
222233
any = true
223234
} else {

‎internal/config/settings_test.go‎

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -146,22 +146,24 @@ func TestTrustedCallerKeyStoredWithTrailingNewlineStillMatches(t *testing.T) {
146146
func TestSharingConfigured(t *testing.T) {
147147
full := func() *Settings {
148148
return &Settings{
149-
SacdAddress: "0x3c152B5d96769661008Ff404224d6530FCAC766d",
150-
VehicleNftAddress: "0xbA5738a18d83D41847dfFbDC6101d37C69c9B0cF",
151-
RPCURL: mustURL(t, "https://polygon-mainnet.example/v2/key"),
152-
BundlerURL: mustURL(t, "https://rpc.zerodev.app/api/v2/bundler/proj"),
153-
ChainID: 137,
149+
SacdAddress: "0x3c152B5d96769661008Ff404224d6530FCAC766d",
150+
SyntheticNftAddress: "0x4804e8D1661cd1a1e5dDdE1ff458A7f878c0aC6D",
151+
VehicleNftAddress: "0xbA5738a18d83D41847dfFbDC6101d37C69c9B0cF",
152+
RPCURL: mustURL(t, "https://polygon-mainnet.example/v2/key"),
153+
BundlerURL: mustURL(t, "https://rpc.zerodev.app/api/v2/bundler/proj"),
154+
ChainID: 137,
154155
}
155156
}
156157

157158
require.True(t, full().SharingConfigured(), "a fully populated config must be considered configured")
158159

159160
for name, blank := range map[string]func(*Settings){
160-
"no SACD address": func(s *Settings) { s.SacdAddress = "" },
161-
"no vehicle NFT address": func(s *Settings) { s.VehicleNftAddress = "" },
162-
"no RPC URL": func(s *Settings) { s.RPCURL = url.URL{} },
163-
"no bundler URL": func(s *Settings) { s.BundlerURL = url.URL{} },
164-
"no chain id": func(s *Settings) { s.ChainID = 0 },
161+
"no SACD address": func(s *Settings) { s.SacdAddress = "" },
162+
"no synthetic NFT address": func(s *Settings) { s.SyntheticNftAddress = "" },
163+
"no vehicle NFT address": func(s *Settings) { s.VehicleNftAddress = "" },
164+
"no RPC URL": func(s *Settings) { s.RPCURL = url.URL{} },
165+
"no bundler URL": func(s *Settings) { s.BundlerURL = url.URL{} },
166+
"no chain id": func(s *Settings) { s.ChainID = 0 },
165167
} {
166168
t.Run(name, func(t *testing.T) {
167169
s := full()
@@ -208,6 +210,7 @@ func TestValidate_RejectsPartialSharingConfiguration(t *testing.T) {
208210
full := func() *Settings {
209211
s := base()
210212
s.SacdAddress = "0x3c152B5d96769661008Ff404224d6530FCAC766d"
213+
s.SyntheticNftAddress = "0x4804e8D1661cd1a1e5dDdE1ff458A7f878c0aC6D"
211214
s.VehicleNftAddress = "0xbA5738a18d83D41847dfFbDC6101d37C69c9B0cF"
212215
s.RPCURL = mustURL(t, "https://polygon-mainnet.example/v2/key")
213216
s.BundlerURL = mustURL(t, "https://rpc.zerodev.example/api/v2/bundler/proj")
@@ -228,11 +231,12 @@ func TestValidate_RejectsPartialSharingConfiguration(t *testing.T) {
228231
})
229232

230233
for name, blank := range map[string]func(*Settings){
231-
"SACD_ADDRESS": func(s *Settings) { s.SacdAddress = "" },
232-
"RPC_URL": func(s *Settings) { s.RPCURL = url.URL{} },
233-
"BUNDLER_URL": func(s *Settings) { s.BundlerURL = url.URL{} },
234-
"VEHICLE_NFT_ADDRESS": func(s *Settings) { s.VehicleNftAddress = "" },
235-
"CHAIN_ID": func(s *Settings) { s.ChainID = 0 },
234+
"SACD_ADDRESS": func(s *Settings) { s.SacdAddress = "" },
235+
"SYNTHETIC_NFT_ADDRESS": func(s *Settings) { s.SyntheticNftAddress = "" },
236+
"RPC_URL": func(s *Settings) { s.RPCURL = url.URL{} },
237+
"BUNDLER_URL": func(s *Settings) { s.BundlerURL = url.URL{} },
238+
"VEHICLE_NFT_ADDRESS": func(s *Settings) { s.VehicleNftAddress = "" },
239+
"CHAIN_ID": func(s *Settings) { s.ChainID = 0 },
236240
} {
237241
t.Run("missing "+name+" refuses to boot", func(t *testing.T) {
238242
s := full()

‎internal/controllers/shared_ops.go‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package controllers
2+
3+
import (
4+
"errors"
5+
"strconv"
6+
7+
"github.com/DIMO-Network/fleet-tenancy-api/internal/models"
8+
"github.com/DIMO-Network/fleet-tenancy-api/internal/service"
9+
"github.com/DIMO-Network/fleet-tenancy-api/internal/sharing"
10+
"github.com/ethereum/go-ethereum/common"
11+
"github.com/gofiber/fiber/v2"
12+
)
13+
14+
// SharedOperation — POST /v1/tenants/:tenantId/vehicles/:tokenId/shared-ops
15+
//
16+
// The typed shared-operations endpoint (docs/plans/06-signer-key-consolidation.md,
17+
// step 3): one of four named operations, signed with the tenant's signer on
18+
// the vehicle owner's kernel account. The body carries an operation ENUM and
19+
// never calldata — a general "sign this" endpoint would be a signing oracle
20+
// over every kernel account any operator's signer can act for, and the narrow
21+
// interface cannot be widened later without that cost, so it starts narrow.
22+
//
23+
// Returns 202 and a job id, like a share and for the same reason: every op
24+
// waits on a bundler for longer than an HTTP request should.
25+
//
26+
// Unlike ShareVehicle there is no per-member capability check here, and the
27+
// difference is deliberate rather than an omission. The expected caller is
28+
// kaufmann's shared-account workers, whose HTTP boundary already gated the
29+
// human (its access middleware), and which call this from a background job
30+
// that carries no session — the same BFF split as invitations, where the
31+
// calling app owns the human check and this service checks the caller tenant's
32+
// scope. Re-checking a member's capability at this remove would also be an
33+
// execution-time re-read of a request-time property, which ShareArgs's
34+
// ActorWallet comment records as the wrong side of that line. What this
35+
// endpoint enforces itself is everything that must be true NOW: caller scope,
36+
// entitlement, live ownership and live signer authority.
37+
func (c *SharingController) SharedOperation(ctx *fiber.Ctx) error {
38+
tenantID := ctx.Params("tenantId")
39+
40+
tokenID, err := strconv.ParseInt(ctx.Params("tokenId"), 10, 64)
41+
if err != nil {
42+
return fiber.NewError(fiber.StatusBadRequest, "tokenId must be a number")
43+
}
44+
45+
var body models.SharedOpInput
46+
if err := ctx.BodyParser(&body); err != nil {
47+
return fiber.NewError(fiber.StatusBadRequest, "invalid request body")
48+
}
49+
if err := c.assertScope(ctx, tenantID, "run shared operation"); err != nil {
50+
return err
51+
}
52+
53+
args := sharing.SharedOpArgs{
54+
TenantID: tenantID,
55+
TokenID: tokenID,
56+
Op: sharing.SharedOp(body.Op),
57+
TargetWallet: body.TargetWallet,
58+
SyntheticTokenID: body.SyntheticTokenID,
59+
ActorWallet: body.ActorWallet,
60+
}
61+
// Shape first, before any upstream call — the op/field matrix needs
62+
// nothing to check. The same validation runs again in the worker, which is
63+
// the last point before calldata is built.
64+
if err := args.Validate(); err != nil {
65+
return fiber.NewError(fiber.StatusBadRequest, err.Error())
66+
}
67+
68+
// The full authorization chain runs here so the caller gets a synchronous
69+
// answer, and again in the worker before the irreversible call. Both are
70+
// necessary: this one is for the caller, that one is for correctness.
71+
owner, _, err := c.shares.AuthorizeShare(ctx.Context(), tenantID, tokenID)
72+
if err != nil {
73+
return c.shareAuthError(ctx, tenantID, tokenID, err)
74+
}
75+
76+
switch args.Op {
77+
case sharing.OpTransferVehicle:
78+
// Only applicable once the owner is known, which is why it is not in
79+
// Validate: transferring a vehicle to its current owner is a no-op
80+
// that would read as success.
81+
if common.HexToAddress(args.TargetWallet) == owner {
82+
return fiber.NewError(fiber.StatusBadRequest, "the vehicle already belongs to targetWallet")
83+
}
84+
case sharing.OpGrantSacd:
85+
// The grant's target is the tenant's own client id; a tenant without
86+
// one has nothing to grant to, and finding that out synchronously
87+
// beats a job that can only fail. The transfer op deliberately skips
88+
// this check — its chained re-share is best-effort, and a tenant
89+
// without a client id can still transfer.
90+
if _, err := c.shares.GranteeClientID(ctx.Context(), tenantID); err != nil {
91+
return c.granteeClientIDError(ctx, tenantID, err)
92+
}
93+
}
94+
95+
jobID, err := c.queue.EnqueueSharedOp(ctx.Context(), args)
96+
if err != nil {
97+
if errors.Is(err, sharing.ErrQueueUnavailable) {
98+
return fiber.NewError(fiber.StatusServiceUnavailable,
99+
"shared operations are not available in this environment")
100+
}
101+
c.logger.Err(err).Str("tenant_id", tenantID).Int64("token_id", tokenID).
102+
Str("op", body.Op).Msg("enqueue shared operation")
103+
return fiber.NewError(fiber.StatusInternalServerError, "failed to queue the operation")
104+
}
105+
106+
return ctx.Status(fiber.StatusAccepted).JSON(models.SharedOpResult{JobID: jobID})
107+
}
108+
109+
// SharedOperationStatus — GET /v1/tenants/:tenantId/vehicles/:tokenId/shared-ops/status?jobId=
110+
//
111+
// Mirrors ShareStatus exactly: same response shape, same tenant scoping, same
112+
// not-found answer for another tenant's job — plus the same answer for a job
113+
// of the other kind, so the two polling surfaces stay distinct despite
114+
// sharing one id sequence.
115+
func (c *SharingController) SharedOperationStatus(ctx *fiber.Ctx) error {
116+
tenantID := ctx.Params("tenantId")
117+
118+
jobID, err := strconv.ParseInt(ctx.Query("jobId"), 10, 64)
119+
if err != nil {
120+
return fiber.NewError(fiber.StatusBadRequest, "jobId is required and must be a number")
121+
}
122+
if err := c.assertScope(ctx, tenantID, "read shared operation status"); err != nil {
123+
return err
124+
}
125+
126+
status, err := c.queue.SharedOpStatus(ctx.Context(), tenantID, jobID)
127+
if err != nil {
128+
switch {
129+
case errors.Is(err, sharing.ErrJobNotFound):
130+
return fiber.NewError(fiber.StatusNotFound, "no such shared-operation job")
131+
case errors.Is(err, sharing.ErrQueueUnavailable):
132+
return fiber.NewError(fiber.StatusServiceUnavailable,
133+
"shared operations are not available in this environment")
134+
}
135+
c.logger.Err(err).Str("tenant_id", tenantID).Int64("job_id", jobID).
136+
Msg("read shared operation status")
137+
return fiber.NewError(fiber.StatusInternalServerError, "failed to read the operation status")
138+
}
139+
return ctx.JSON(status)
140+
}
141+
142+
// granteeClientIDError maps a failed grantee resolution: a missing credential
143+
// or client id is the tenant's configuration state (409, an operator can fix
144+
// it), everything else is upstream (500 — this resolution is a local read).
145+
func (c *SharingController) granteeClientIDError(ctx *fiber.Ctx, tenantID string, err error) error {
146+
switch {
147+
case errors.Is(err, service.ErrNoClientID), errors.Is(err, service.ErrNoCredential):
148+
return fiber.NewError(fiber.StatusConflict, "this tenant has no DIMO client id to grant to")
149+
case errors.Is(err, service.ErrTenantNotFound):
150+
return fiber.NewError(fiber.StatusForbidden, "unknown tenant")
151+
}
152+
c.logger.Err(err).Str("tenant_id", tenantID).Msg("resolve grantee client id")
153+
return fiber.NewError(fiber.StatusInternalServerError, "failed to resolve the tenant's client id")
154+
}

0 commit comments

Comments
 (0)