@stackonward/cli 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/index.js +8 -8
  2. package/dist/index.js.map +1 -1
  3. package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +14 -2
  4. package/dist/resources/components/nuxt/site-source/app/components/content/CallToAction.vue +3 -1
  5. package/dist/resources/components/nuxt/site-source/app/components/content/Faq.vue +2 -2
  6. package/dist/resources/components/nuxt/site-source/app/components/content/Reviews.vue +2 -2
  7. package/dist/resources/components/nuxt/site-source/app/components/steps/Showcase.vue +2 -2
  8. package/dist/resources/components/nuxt/site-source/app/composables/useWebPush.ts +4 -0
  9. package/dist/resources/components/nuxt/site-source/app/plugins/web-push.ts +52 -0
  10. package/dist/resources/components/nuxt/site-source/app/theme/button.ts +18 -22
  11. package/dist/resources/components/nuxt/site-source/app/theme/index.ts +24 -0
  12. package/dist/resources/components/nuxt/site-source/app/theme/input.ts +32 -17
  13. package/dist/resources/components/nuxt/site-source/app/theme/modal.ts +1 -1
  14. package/dist/resources/components/nuxt/site-source/app/theme/select.ts +62 -18
  15. package/dist/resources/components/nuxt/site-source/app/theme/textarea.ts +73 -1
  16. package/dist/resources/components/nuxt/site-source/app/web-push/worker.ts +8 -0
  17. package/dist/resources/components/registry.json +11 -1
  18. package/dist/resources/registry.json +15 -3
  19. package/dist/resources/templates/base/{.claude → .codex}/docs/architecture-template.md +1 -1
  20. package/dist/resources/templates/base/{.claude → .codex}/docs/architecture.md +1 -1
  21. package/dist/resources/templates/base/{.claude → .codex}/docs/index.md +3 -3
  22. package/dist/resources/templates/base/{.claude → .codex}/docs/verification.md +1 -1
  23. package/dist/resources/templates/base/AGENTS.md +24 -24
  24. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/api-design.md +1 -1
  25. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/architecture-template.md +1 -1
  26. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/check-list.md +3 -3
  27. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/database.md +1 -1
  28. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/error-handling.md +1 -1
  29. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/index.md +1 -1
  30. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/logging.md +1 -1
  31. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/mediagen-provider-onboarding-sop.md +2 -2
  32. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/mediagen-provider-quick-reference.md +1 -1
  33. package/dist/resources/templates/go-backend/{.claude → .codex}/docs/module-standards.md +1 -1
  34. package/dist/resources/templates/go-backend/.dockerignore +1 -2
  35. package/dist/resources/templates/go-backend/AGENTS.md +19 -19
  36. package/dist/resources/templates/go-backend/README.md +5 -1
  37. package/dist/resources/templates/go-backend/deployments/onex/product-onboarding.example.json +201 -0
  38. package/dist/resources/templates/go-backend/docs/onex-notifications.md +85 -0
  39. package/dist/resources/templates/go-backend/docs/openapi-provenance.json +1 -1
  40. package/dist/resources/templates/go-backend/docs/runtime-provenance.json +23 -2
  41. package/dist/resources/templates/go-backend/docs/source-provenance.json +124 -6
  42. package/dist/resources/templates/go-backend/docs/standards-provenance.json +30 -30
  43. package/dist/resources/templates/go-backend/internal/common/middleware/cors.go +1 -0
  44. package/dist/resources/templates/go-backend/internal/common/middleware/middleware_test.go +1 -0
  45. package/dist/resources/templates/go-backend/internal/core/httpclient/safe_post.go +28 -0
  46. package/dist/resources/templates/go-backend/internal/core/httpclient/safe_post_test.go +56 -0
  47. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/client.go +453 -0
  48. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/client_test.go +772 -0
  49. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/codec.go +33 -0
  50. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/error.go +146 -0
  51. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/error_notification_test.go +29 -0
  52. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/client.go +132 -0
  53. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/client_test.go +222 -0
  54. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/contracts.go +219 -0
  55. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/resilience.go +23 -0
  56. package/dist/resources/templates/go-backend/internal/integrations/onex/v1/transport_policy.go +96 -0
  57. package/dist/resources/templates/go-backend/internal/products/README.md +1 -1
  58. package/dist/resources/templates/go-backend/internal/sharedevents/README.md +1 -1
  59. package/dist/resources/templates/go-backend/scripts/check-openapi-clean-deletion.sh +3 -3
  60. package/dist/resources/templates/mcp-server/.prettierignore +1 -0
  61. package/dist/resources/templates/nuxt/{.claude → .codex}/docs/stack.md +17 -1
  62. package/dist/resources/templates/nuxt/.dockerignore +1 -1
  63. package/dist/resources/templates/nuxt/.env.example +6 -0
  64. package/dist/resources/templates/nuxt/.prettierignore +2 -1
  65. package/dist/resources/templates/nuxt/DESIGN.md +55 -10
  66. package/dist/resources/templates/nuxt/README.md +8 -0
  67. package/dist/resources/templates/nuxt/nuxt.config.ts +13 -1
  68. package/dist/resources/templates/nuxt/package.json +4 -2
  69. package/package.json +1 -1
  70. package/dist/resources/templates/base/CLAUDE.md +0 -437
  71. package/dist/resources/templates/go-backend/CLAUDE.md +0 -1139
  72. /package/dist/resources/templates/base/{.claude → .codex}/docs/security-lifecycle.md +0 -0
  73. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/e2e-standards.md +0 -0
  74. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/event-runtime-boundary.md +0 -0
  75. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/identifier-contract.md +0 -0
  76. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/openapi-contract.md +0 -0
  77. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/payment-gateway-onboarding.md +0 -0
  78. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/platform-boundary.md +0 -0
  79. /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/quality-gates.md +0 -0
  80. /package/dist/resources/templates/mcp-server/{.claude → .codex}/docs/stack.md +0 -0
@@ -0,0 +1,33 @@
1
+ package v1
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+
9
+ commonvalidator "{{module}}/internal/common/validator"
10
+ )
11
+
12
+ // DecodeStrict 拒绝重复字段、未知字段、空响应和多个 JSON 值。
13
+ func DecodeStrict(body []byte, out any) error {
14
+ if len(bytes.TrimSpace(body)) == 0 || out == nil {
15
+ return fmt.Errorf("JSON body and decode target are required")
16
+ }
17
+ if err := commonvalidator.RejectDuplicateJSONFields(body); err != nil {
18
+ return err
19
+ }
20
+ decoder := json.NewDecoder(bytes.NewReader(body))
21
+ decoder.DisallowUnknownFields()
22
+ if err := decoder.Decode(out); err != nil {
23
+ return err
24
+ }
25
+ var trailing any
26
+ if err := decoder.Decode(&trailing); err != io.EOF {
27
+ if err == nil {
28
+ return fmt.Errorf("JSON body contains multiple values")
29
+ }
30
+ return err
31
+ }
32
+ return nil
33
+ }
@@ -0,0 +1,146 @@
1
+ package v1
2
+
3
+ import (
4
+ "fmt"
5
+ "net/http"
6
+ "strconv"
7
+ "time"
8
+
9
+ "{{module}}/internal/core/httpclient"
10
+ "{{module}}/pkg/apierror"
11
+ )
12
+
13
+ // ErrorKind 是产品可安全分支处理的 OneX 边界错误闭集。
14
+ type ErrorKind string
15
+
16
+ const (
17
+ ErrorKindValidation ErrorKind = "validation"
18
+ ErrorKindUnauthenticated ErrorKind = "unauthenticated"
19
+ ErrorKindForbidden ErrorKind = "forbidden"
20
+ ErrorKindNotFound ErrorKind = "not_found"
21
+ ErrorKindConflict ErrorKind = "conflict"
22
+ ErrorKindRateLimited ErrorKind = "rate_limited"
23
+ ErrorKindUnavailable ErrorKind = "unavailable"
24
+ ErrorKindResultUnknown ErrorKind = "result_unknown"
25
+ ErrorKindContract ErrorKind = "contract"
26
+ ErrorKindCanceled ErrorKind = "canceled"
27
+ )
28
+
29
+ // Error 保留稳定错误代码和重试语义,不向产品传播上游消息。
30
+ type Error struct {
31
+ Kind ErrorKind
32
+ ErrorType string
33
+ Code string
34
+ HTTPStatus int
35
+ Retryable bool
36
+ RetryAfter string
37
+ // CurrentRegistrationRevision 仅保留安装能力已验证后的原生设备冲突恢复提示。
38
+ CurrentRegistrationRevision *int64
39
+ cause error
40
+ }
41
+
42
+ // Error 返回不含上游消息或秘密的稳定描述。
43
+ func (e *Error) Error() string {
44
+ if e == nil {
45
+ return "onex request failed"
46
+ }
47
+ return fmt.Sprintf("onex request failed: kind=%s code=%s", e.Kind, e.Code)
48
+ }
49
+
50
+ // Unwrap 返回仅供错误链判断使用的底层原因。
51
+ func (e *Error) Unwrap() error {
52
+ if e == nil {
53
+ return nil
54
+ }
55
+ return e.cause
56
+ }
57
+
58
+ // RetryAfterDelay 复用传输层对受控 Retry-After 的解析,供持久恢复安排下一次读取。
59
+ func (e *Error) RetryAfterDelay(now time.Time) time.Duration {
60
+ if e == nil {
61
+ return 0
62
+ }
63
+ delay, _ := parseRetryAfter(e.RetryAfter, now)
64
+ return delay
65
+ }
66
+
67
+ // AffectsDependencyAvailability 区分业务拒绝与会降低依赖可用性的边界失败。
68
+ func (e *Error) AffectsDependencyAvailability() bool {
69
+ if e == nil {
70
+ return false
71
+ }
72
+ switch e.Kind {
73
+ case ErrorKindRateLimited, ErrorKindUnavailable, ErrorKindResultUnknown, ErrorKindContract, ErrorKindUnauthenticated, ErrorKindForbidden:
74
+ return true
75
+ default:
76
+ return false
77
+ }
78
+ }
79
+
80
+ // NewValidationError 构造不会发起网络请求的边界校验错误。
81
+ func NewValidationError(code string) *Error {
82
+ return &Error{Kind: ErrorKindValidation, Code: code}
83
+ }
84
+
85
+ // NewContractError 构造拒绝上游 schema 漂移的合同错误。
86
+ func NewContractError(code string, cause error) *Error {
87
+ return &Error{Kind: ErrorKindContract, Code: code, cause: cause}
88
+ }
89
+
90
+ func decodeHTTPError(response *httpclient.Response) error {
91
+ kind, retryable := errorKindFromStatus(response.StatusCode)
92
+ result := &Error{
93
+ Kind: kind,
94
+ ErrorType: "",
95
+ Code: "onex_request_failed",
96
+ HTTPStatus: response.StatusCode,
97
+ Retryable: retryable,
98
+ }
99
+ fallbackCode := "onex_error_contract_invalid"
100
+ decoded := apierror.DecodeHTTPResponse(response.StatusCode, response.Headers, response.Body, apierror.New(http.StatusBadGateway, apierror.TypeAPI, fallbackCode, "Invalid OneX error contract"))
101
+ if decoded.Detail.Code == fallbackCode {
102
+ result.Kind = ErrorKindContract
103
+ result.Code = fallbackCode
104
+ return result
105
+ }
106
+ result.Code = decoded.Detail.Code
107
+ result.ErrorType = decoded.Detail.Type
108
+ result.RetryAfter = decoded.HTTPResponseMetadata().RetryAfter()
109
+ if result.Code == "native_device_revision_conflict" && response.StatusCode == http.StatusConflict {
110
+ for _, field := range decoded.Detail.Errors {
111
+ if field.Field != "current_registration_revision" {
112
+ continue
113
+ }
114
+ revision, err := strconv.ParseInt(field.Message, 10, 64)
115
+ if err != nil || revision < 1 || strconv.FormatInt(revision, 10) != field.Message || result.CurrentRegistrationRevision != nil {
116
+ result.Kind = ErrorKindContract
117
+ result.Code = "onex_error_contract_invalid"
118
+ result.CurrentRegistrationRevision = nil
119
+ return result
120
+ }
121
+ result.CurrentRegistrationRevision = &revision
122
+ }
123
+ }
124
+ return result
125
+ }
126
+
127
+ func errorKindFromStatus(status int) (ErrorKind, bool) {
128
+ switch status {
129
+ case http.StatusBadRequest, http.StatusUnprocessableEntity:
130
+ return ErrorKindValidation, false
131
+ case http.StatusUnauthorized:
132
+ return ErrorKindUnauthenticated, false
133
+ case http.StatusForbidden:
134
+ return ErrorKindForbidden, false
135
+ case http.StatusNotFound:
136
+ return ErrorKindNotFound, false
137
+ case http.StatusConflict:
138
+ return ErrorKindConflict, false
139
+ case http.StatusTooManyRequests:
140
+ return ErrorKindRateLimited, true
141
+ case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
142
+ return ErrorKindUnavailable, true
143
+ default:
144
+ return ErrorKindContract, false
145
+ }
146
+ }
@@ -0,0 +1,29 @@
1
+ package v1
2
+
3
+ import (
4
+ "fmt"
5
+ "net/http"
6
+ "testing"
7
+
8
+ "github.com/stretchr/testify/require"
9
+
10
+ "{{module}}/internal/core/httpclient"
11
+ )
12
+
13
+ func TestNativeDeviceConflictOnlyAcceptsCanonicalPositiveRevision(t *testing.T) {
14
+ for _, value := range []string{"7", "0", "-1", "07", "token-secret"} {
15
+ t.Run(value, func(t *testing.T) {
16
+ err := decodeHTTPError(&httpclient.Response{StatusCode: http.StatusConflict, Headers: http.Header{"Content-Type": []string{"application/json"}}, Body: []byte(fmt.Sprintf(`{"error":{"type":"api_error","code":"native_device_revision_conflict","message":"private upstream text","errors":[{"field":"current_registration_revision","message":%q}]}}`, value))})
17
+ var result *Error
18
+ require.ErrorAs(t, err, &result)
19
+ if value == "7" {
20
+ require.NotNil(t, result.CurrentRegistrationRevision)
21
+ require.EqualValues(t, 7, *result.CurrentRegistrationRevision)
22
+ } else {
23
+ require.Nil(t, result.CurrentRegistrationRevision)
24
+ require.Equal(t, ErrorKindContract, result.Kind)
25
+ }
26
+ require.NotContains(t, result.Error(), "private upstream text")
27
+ })
28
+ }
29
+ }
@@ -0,0 +1,132 @@
1
+ package notification
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+ "net/url"
8
+ "strconv"
9
+ "strings"
10
+ "time"
11
+
12
+ onexv1 "{{module}}/internal/integrations/onex/v1"
13
+ )
14
+
15
+ // Transport 借用已有 OneX 传输,不创建签名或连接生命周期。
16
+ type Transport interface {
17
+ Do(context.Context, onexv1.Call, any) error
18
+ }
19
+
20
+ // Client 借用统一 transport 执行平台既有通知协议。
21
+ type Client struct {
22
+ transport Transport
23
+ timeout time.Duration
24
+ }
25
+
26
+ // NewClient 仅保存依赖,构造过程不会发送通知或访问网络。
27
+ func NewClient(transport Transport, timeout time.Duration) (*Client, error) {
28
+ if transport == nil || timeout <= 0 {
29
+ return nil, fmt.Errorf("notification transport and timeout are required")
30
+ }
31
+ return &Client{transport: transport, timeout: timeout}, nil
32
+ }
33
+
34
+ // QueryInbox 查询原生收件箱,不裁剪过滤条件或展示字段。
35
+ func (c *Client) QueryInbox(ctx context.Context, request InboxQuery) (InboxPage, error) {
36
+ var result InboxPage
37
+ err := c.call(ctx, http.MethodPost, "/native-notifications/inbox-queries", request.UserID, request, nil, http.StatusOK, "native_write", &result)
38
+ return result, err
39
+ }
40
+
41
+ // GetMessage 读取接收人的精确消息,不允许路径段穿越。
42
+ func (c *Client) GetMessage(ctx context.Context, userID int64, messageNo string) (InboxMessage, error) {
43
+ var result InboxMessage
44
+ if len(messageNo) < 5 || len(messageNo) > 40 || messageNo != strings.TrimSpace(messageNo) || strings.ContainsAny(messageNo, "/\\%?#\x00\r\n") {
45
+ return result, onexv1.NewValidationError("onex_notification_message_invalid")
46
+ }
47
+ err := c.call(ctx, http.MethodGet, "/native-notifications/inbox-messages/"+url.PathEscape(messageNo), userID, nil, userQuery(userID), http.StatusOK, "native_read", &result)
48
+ return result, err
49
+ }
50
+
51
+ // CountUnread 返回原生未读汇总。
52
+ func (c *Client) CountUnread(ctx context.Context, request InboxQuery) (Unread, error) {
53
+ var result Unread
54
+ err := c.call(ctx, http.MethodPost, "/native-notifications/unread-queries", request.UserID, request, nil, http.StatusOK, "native_write", &result)
55
+ return result, err
56
+ }
57
+
58
+ // AcknowledgeMessages 只确认请求中的消息版本。
59
+ func (c *Client) AcknowledgeMessages(ctx context.Context, request AcknowledgeMessagesRequest) (Acknowledgement, error) {
60
+ var result Acknowledgement
61
+ err := c.call(ctx, http.MethodPost, "/native-notifications/message-acknowledgements", request.UserID, request, nil, http.StatusOK, "native_write", &result)
62
+ return result, err
63
+ }
64
+
65
+ // AcknowledgeScope 确认调用方显式指定的原生查询范围。
66
+ func (c *Client) AcknowledgeScope(ctx context.Context, request InboxQuery) (Acknowledgement, error) {
67
+ var result Acknowledgement
68
+ err := c.call(ctx, http.MethodPost, "/native-notifications/acknowledgements", request.UserID, request, nil, http.StatusOK, "native_write", &result)
69
+ return result, err
70
+ }
71
+
72
+ // RegisterDevice 透传可信账号与会话,保留安装版本冲突。
73
+ func (c *Client) RegisterDevice(ctx context.Context, request DeviceRegistration) (Device, error) {
74
+ var result Device
75
+ err := c.call(ctx, http.MethodPost, "/native-notifications/devices", request.UserID, request, nil, http.StatusCreated, "native_write", &result)
76
+ return result, err
77
+ }
78
+
79
+ // RevokeDevice 以安装能力注销,不携带账号会话。
80
+ func (c *Client) RevokeDevice(ctx context.Context, request DeviceRevocation) error {
81
+ return c.call(ctx, http.MethodPost, "/native-notifications/device-revocations", 0, request, nil, http.StatusNoContent, "native_write", nil)
82
+ }
83
+
84
+ // ListDevices 查询接收人的有效设备列表。
85
+ func (c *Client) ListDevices(ctx context.Context, userID int64) (DeviceList, error) {
86
+ var result DeviceList
87
+ err := c.call(ctx, http.MethodGet, "/native-notifications/devices", userID, nil, userQuery(userID), http.StatusOK, "native_read", &result)
88
+ return result, err
89
+ }
90
+
91
+ // GetPreferences 按调用方提供的分类和默认值读取偏好。
92
+ func (c *Client) GetPreferences(ctx context.Context, request PreferenceQuery) (PushPreferences, error) {
93
+ var result PushPreferences
94
+ err := c.call(ctx, http.MethodPost, "/native-notifications/push-preference-queries", request.UserID, request, nil, http.StatusOK, "native_write", &result)
95
+ return result, err
96
+ }
97
+
98
+ // PutPreferences 返回平台接受的完整偏好。
99
+ func (c *Client) PutPreferences(ctx context.Context, request PreferenceUpdate) (PushPreferences, error) {
100
+ var result PushPreferences
101
+ err := c.call(ctx, http.MethodPut, "/native-notifications/push-preferences", request.UserID, request, nil, http.StatusOK, "native_write", &result)
102
+ return result, err
103
+ }
104
+
105
+ // SendTestPush 只执行调用方显式请求,平台仍校验诊断权限与运行环境。
106
+ func (c *Client) SendTestPush(ctx context.Context, request TestPushRequest) (TestPushResult, error) {
107
+ var result TestPushResult
108
+ err := c.call(ctx, http.MethodPost, "/native-notifications/test-push-sends", request.UserID, request, nil, http.StatusOK, "native_write", &result)
109
+ return result, err
110
+ }
111
+
112
+ // StartDispatch 提交冻结的产品事实,不选择模板或提供商。
113
+ func (c *Client) StartDispatch(ctx context.Context, request DispatchStartRequest) (DispatchStartResult, error) {
114
+ var result DispatchStartResult
115
+ err := c.call(ctx, http.MethodPost, "/notifications/dispatch-command-starts", 0, request, nil, http.StatusCreated, "dispatch_command_start", &result)
116
+ return result, err
117
+ }
118
+
119
+ // AdvanceSource 推进产品事项版本,使旧通知失效。
120
+ func (c *Client) AdvanceSource(ctx context.Context, request SourceAdvanceRequest) (SourceAdvanceResult, error) {
121
+ var result SourceAdvanceResult
122
+ err := c.call(ctx, http.MethodPost, "/notifications/source-state-advancements", 0, request, nil, http.StatusOK, "advance_source_state", &result)
123
+ return result, err
124
+ }
125
+
126
+ func (c *Client) call(ctx context.Context, method, path string, userID int64, body any, query url.Values, status int, operation string, out any) error {
127
+ return c.transport.Do(ctx, onexv1.Call{Method: method, Path: path, UserID: userID, Body: body, Query: query, ExpectedStatuses: []int{status}, Dependency: "notification", Operation: operation, Timeout: c.timeout}, out)
128
+ }
129
+
130
+ func userQuery(userID int64) url.Values {
131
+ return url.Values{"user_id": {strconv.FormatInt(userID, 10)}}
132
+ }
@@ -0,0 +1,222 @@
1
+ package notification
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "io"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "sync/atomic"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/stretchr/testify/require"
15
+ "go.uber.org/zap"
16
+
17
+ "{{module}}/internal/common/types"
18
+ "{{module}}/internal/core/httpclient"
19
+ onexv1 "{{module}}/internal/integrations/onex/v1"
20
+ )
21
+
22
+ type wireSigner struct{}
23
+
24
+ func (wireSigner) AuthHeaders(context.Context) (map[string]string, error) {
25
+ return map[string]string{"Authorization": "Bearer local-contract-test"}, nil
26
+ }
27
+ func (wireSigner) AuthorizeRequest(context.Context, *http.Request) error { return nil }
28
+
29
+ const wireMessage = `{"delivery_id":10,"message_id":11,"message_no":"inm_sample","source_version":3,"title":"Status","subtitle":"Detail","content":"Ready","cover_url":"https://example.com/cover.png","action_url":"https://example.com/orders/one","category":"transactional","metadata":{"topic":"order"},"status":"unread","target_ref":"order:one","target_kind":"order","read_at":"2026-10-15T00:00:00Z","delivered_at":"2026-10-14T00:00:00Z","created_at":"2026-10-14T00:00:00Z","updated_at":"2026-10-15T00:00:00Z"}`
30
+ const wireDevice = `{"id":7,"installation_id":"7b5dbd2e-c08c-4c09-b03a-e43ac0a70c60","application_code":"mobile","environment":"production","registration_revision":4,"provider":"fcm","platform":"android","app_version":"1.0","device_model":"phone","os_version":"16","status":"active","last_active_at":"2026-10-15T00:00:00Z","created_at":"2026-10-14T00:00:00Z"}`
31
+ const wirePreferences = `{"enabled":false,"categories":{"orders":true,"offers":false},"quiet_hours":{"enabled":true,"start":"21:00","end":"07:00","timezone":"Asia/Tokyo"}}`
32
+ const wireQuery = `{"user_id":42,"categories":["orders"],"target_ref":"order:one","target_scope":"any","read_status":"unread","delivery_ids":[10],"message_ids":[11],"page":2,"page_size":20}`
33
+ const wireSource = `{"id":"d5812c28-9907-4a7c-8b9a-09317f1c9673","type":"orders.ready","subject_ref":"order:one","subject_version":3,"occurred_at":"2026-10-15T00:00:00Z"}`
34
+
35
+ func TestClientCompleteNativeWireForTwoProducts(t *testing.T) {
36
+ query := InboxQuery{UserID: 42, Categories: []string{"orders"}, TargetRef: "order:one", TargetScope: "any", ReadStatus: "unread", DeliveryIDs: []int64{10}, MessageIDs: []int64{11}, Page: 2, PageSize: 20}
37
+ prefs := PushPreferences{Enabled: false, Categories: map[string]bool{"orders": true, "offers": false}, QuietHours: QuietHours{Enabled: true, Start: "21:00", End: "07:00", Timezone: "Asia/Tokyo"}}
38
+ source := SourceEvent{ID: "d5812c28-9907-4a7c-8b9a-09317f1c9673", Type: "orders.ready", SubjectRef: "order:one", SubjectVersion: 3, OccurredAt: time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC)}
39
+ registration := DeviceRegistration{UserID: 42, SessionID: 81, ApplicationCode: "mobile", Environment: "production", InstallationID: "7b5dbd2e-c08c-4c09-b03a-e43ac0a70c60", InstallationKey: "private-installation-key", RegistrationRevision: 4, DeviceToken: "private-device-token", Platform: "android", Provider: "fcm", AppVersion: "1.0", DeviceModel: "phone", OSVersion: "16"}
40
+ revocation := DeviceRevocation{ApplicationCode: "mobile", Environment: "production", InstallationID: registration.InstallationID, InstallationKey: registration.InstallationKey, RegistrationRevision: 5}
41
+ tests := []struct {
42
+ name, method, path, query, user, body, response string
43
+ status int
44
+ call func(context.Context, *Client) (any, error)
45
+ }{
46
+ {"inbox", http.MethodPost, "/native-notifications/inbox-queries", "", "42", wireQuery, `{"items":[` + wireMessage + `],"total":1,"unread_count":1,"unread_summary":{"orders":1}}`, 200,
47
+ func(ctx context.Context, c *Client) (any, error) { return c.QueryInbox(ctx, query) }},
48
+ {"message", http.MethodGet, "/native-notifications/inbox-messages/inm_sample", "user_id=42", "42", "", wireMessage, 200,
49
+ func(ctx context.Context, c *Client) (any, error) { return c.GetMessage(ctx, 42, "inm_sample") }},
50
+ {"unread", http.MethodPost, "/native-notifications/unread-queries", "", "42", wireQuery, `{"unread_count":1,"unread_summary":{"orders":1}}`, 200,
51
+ func(ctx context.Context, c *Client) (any, error) { return c.CountUnread(ctx, query) }},
52
+ {"message acknowledgement", http.MethodPost, "/native-notifications/message-acknowledgements", "", "42", `{"user_id":42,"messages":[{"message_no":"inm_sample","source_version":3}]}`, `{"updated":1,"unread_count":0,"unread_summary":{"orders":0}}`, 200,
53
+ func(ctx context.Context, c *Client) (any, error) {
54
+ return c.AcknowledgeMessages(ctx, AcknowledgeMessagesRequest{UserID: 42, Messages: []MessageVersion{{MessageNo: "inm_sample", SourceVersion: 3}}})
55
+ }},
56
+ {"scope acknowledgement", http.MethodPost, "/native-notifications/acknowledgements", "", "42", wireQuery, `{"updated":1,"unread_count":0,"unread_summary":{"orders":0}}`, 200,
57
+ func(ctx context.Context, c *Client) (any, error) { return c.AcknowledgeScope(ctx, query) }},
58
+ {"register", http.MethodPost, "/native-notifications/devices", "", "42", `{"user_id":42,"session_id":81,"application_code":"mobile","environment":"production","installation_id":"7b5dbd2e-c08c-4c09-b03a-e43ac0a70c60","installation_key":"private-installation-key","registration_revision":4,"device_token":"private-device-token","platform":"android","provider":"fcm","app_version":"1.0","device_model":"phone","os_version":"16"}`, wireDevice, 201,
59
+ func(ctx context.Context, c *Client) (any, error) { return c.RegisterDevice(ctx, registration) }},
60
+ {"revoke", http.MethodPost, "/native-notifications/device-revocations", "", "", `{"application_code":"mobile","environment":"production","installation_id":"7b5dbd2e-c08c-4c09-b03a-e43ac0a70c60","installation_key":"private-installation-key","registration_revision":5}`, "", 204,
61
+ func(ctx context.Context, c *Client) (any, error) { return nil, c.RevokeDevice(ctx, revocation) }},
62
+ {"devices", http.MethodGet, "/native-notifications/devices", "user_id=42", "42", "", `{"devices":[` + wireDevice + `]}`, 200,
63
+ func(ctx context.Context, c *Client) (any, error) { return c.ListDevices(ctx, 42) }},
64
+ {"preferences", http.MethodPost, "/native-notifications/push-preference-queries", "", "42", `{"user_id":42,"categories":["orders","offers"],"defaults":` + wirePreferences + `}`, wirePreferences, 200,
65
+ func(ctx context.Context, c *Client) (any, error) {
66
+ return c.GetPreferences(ctx, PreferenceQuery{UserID: 42, Categories: []string{"orders", "offers"}, Defaults: prefs})
67
+ }},
68
+ {"update preferences", http.MethodPut, "/native-notifications/push-preferences", "", "42", `{"user_id":42,"preferences":` + wirePreferences + `}`, wirePreferences, 200,
69
+ func(ctx context.Context, c *Client) (any, error) {
70
+ return c.PutPreferences(ctx, PreferenceUpdate{UserID: 42, Preferences: prefs})
71
+ }},
72
+ {"explicit diagnostic", http.MethodPost, "/native-notifications/test-push-sends", "", "42", `{"user_id":42,"title":"Diagnostic","body":"Explicit test","device_ids":[7]}`, `{"succeeded":1,"failed":0}`, 200,
73
+ func(ctx context.Context, c *Client) (any, error) {
74
+ return c.SendTestPush(ctx, TestPushRequest{UserID: 42, Title: "Diagnostic", Body: "Explicit test", DeviceIDs: []int64{7}})
75
+ }},
76
+ {"start", http.MethodPost, "/notifications/dispatch-command-starts", "", "", `{"source_event":` + wireSource + `,"command_key":"orders.ready","channel":"in_app","context":{"type":"product_event","reference":"order:one:revision:3"},"recipient":{"user_public_id":"usr_recipient"},"locale":"en","target":{"kind":"order","reference":"order:one"},"business_variables":{"order_no":"one","quantity":2}}`, `{"command_uid":"ndc_one","status":"pending","expires_at":"2026-10-16T00:00:00Z","replay":true}`, 201,
77
+ func(ctx context.Context, c *Client) (any, error) {
78
+ return c.StartDispatch(ctx, DispatchStartRequest{SourceEvent: source, CommandKey: "orders.ready", Channel: "in_app", Context: InputContext{Type: "product_event", Reference: "order:one:revision:3"}, Recipient: Recipient{UserPublicID: "usr_recipient"}, Locale: "en", Target: Target{Kind: "order", Reference: "order:one"}, BusinessVariables: map[string]any{"order_no": "one", "quantity": 2}})
79
+ }},
80
+ {"advance", http.MethodPost, "/notifications/source-state-advancements", "", "", `{"source_event":` + wireSource + `,"recipient":{"user_public_id":"usr_recipient"}}`, `{"status":"superseded","source_version":4}`, 200,
81
+ func(ctx context.Context, c *Client) (any, error) {
82
+ return c.AdvanceSource(ctx, SourceAdvanceRequest{SourceEvent: source, Recipient: Recipient{UserPublicID: "usr_recipient"}})
83
+ }},
84
+ }
85
+ require.Len(t, tests, 13)
86
+ for _, product := range []string{"sample_shop", "sample_media"} {
87
+ for _, test := range tests {
88
+ t.Run(product+"/"+test.name, func(t *testing.T) {
89
+ var requests atomic.Int32
90
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
91
+ requests.Add(1)
92
+ require.Equal(t, test.method, r.Method)
93
+ require.Equal(t, "/internal/v1"+test.path, r.URL.Path)
94
+ require.Equal(t, test.query, r.URL.RawQuery)
95
+ require.Equal(t, product, r.Header.Get(types.HeaderOneXProductCode))
96
+ require.Equal(t, test.user, r.Header.Get(types.HeaderInternalUserID))
97
+ require.Equal(t, "Bearer local-contract-test", r.Header.Get("Authorization"))
98
+ require.Empty(t, r.Header.Get("X-Session-ID"))
99
+ require.Empty(t, r.Header.Get("Cookie"))
100
+ raw, err := io.ReadAll(r.Body)
101
+ require.NoError(t, err)
102
+ if test.body == "" {
103
+ require.Empty(t, raw)
104
+ } else {
105
+ require.JSONEq(t, test.body, string(raw))
106
+ }
107
+ w.Header().Set("Content-Type", "application/json")
108
+ w.WriteHeader(test.status)
109
+ _, err = w.Write([]byte(test.response))
110
+ require.NoError(t, err)
111
+ }))
112
+ t.Cleanup(server.Close)
113
+ transport, err := onexv1.NewClient(httpclient.New(zap.NewNop()), wireSigner{}, onexv1.Config{BaseURL: server.URL + "/internal/v1", ProductCode: product, Timeout: time.Second})
114
+ require.NoError(t, err)
115
+ client, err := NewClient(transport, time.Second)
116
+ require.NoError(t, err)
117
+ require.Zero(t, requests.Load())
118
+ result, err := test.call(t.Context(), client)
119
+ require.NoError(t, err)
120
+ require.EqualValues(t, 1, requests.Load())
121
+ if test.response != "" {
122
+ raw, err := json.Marshal(result)
123
+ require.NoError(t, err)
124
+ require.JSONEq(t, test.response, string(raw))
125
+ } else {
126
+ require.Nil(t, result)
127
+ }
128
+ })
129
+ }
130
+ }
131
+ }
132
+
133
+ func TestClientPreservesTypedErrorsAndDoesNotRetryWrites(t *testing.T) {
134
+ for _, test := range []struct {
135
+ name, body, code string
136
+ status int
137
+ kind onexv1.ErrorKind
138
+ }{
139
+ {"device revision", `{"error":{"type":"invalid_request_error","code":"native_device_revision_conflict","message":"private-device-token","errors":[{"field":"current_registration_revision","message":"9"}]}}`, "native_device_revision_conflict", 409, onexv1.ErrorKindConflict},
140
+ {"source superseded", `{"error":{"type":"invalid_request_error","code":"notification_source_superseded","message":"private-device-token"}}`, "notification_source_superseded", 409, onexv1.ErrorKindConflict},
141
+ {"unknown field", `{"id":7,"unexpected":true}`, "onex_response_contract_invalid", 201, onexv1.ErrorKindContract},
142
+ {"connection closed", "", "onex_result_unknown", 0, onexv1.ErrorKindResultUnknown},
143
+ } {
144
+ t.Run(test.name, func(t *testing.T) {
145
+ var requests atomic.Int32
146
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
147
+ requests.Add(1)
148
+ if test.status == 0 {
149
+ connection, _, err := w.(http.Hijacker).Hijack()
150
+ require.NoError(t, err)
151
+ require.NoError(t, connection.Close())
152
+ return
153
+ }
154
+ w.Header().Set("Content-Type", "application/json")
155
+ w.WriteHeader(test.status)
156
+ _, err := w.Write([]byte(test.body))
157
+ require.NoError(t, err)
158
+ }))
159
+ t.Cleanup(server.Close)
160
+ transport, err := onexv1.NewClient(httpclient.New(zap.NewNop()), wireSigner{}, onexv1.Config{BaseURL: server.URL + "/internal/v1", ProductCode: "sample_shop", Timeout: time.Second, SafeReadRetry: onexv1.SafeReadRetryConfig{MaxRetries: 2, InitialBackoff: time.Nanosecond, MaxBackoff: time.Nanosecond}})
161
+ require.NoError(t, err)
162
+ client, err := NewClient(transport, time.Second)
163
+ require.NoError(t, err)
164
+ _, err = client.RegisterDevice(t.Context(), DeviceRegistration{UserID: 42, DeviceToken: "private-device-token"})
165
+ var upstream *onexv1.Error
166
+ require.ErrorAs(t, err, &upstream)
167
+ require.Equal(t, test.kind, upstream.Kind)
168
+ require.Equal(t, test.code, upstream.Code)
169
+ require.NotContains(t, upstream.Error(), "private-device-token")
170
+ if test.name == "device revision" {
171
+ require.NotNil(t, upstream.CurrentRegistrationRevision)
172
+ require.EqualValues(t, 9, *upstream.CurrentRegistrationRevision)
173
+ }
174
+ require.EqualValues(t, 1, requests.Load())
175
+ })
176
+ }
177
+ }
178
+
179
+ type wireCapture struct {
180
+ calls []onexv1.Call
181
+ err error
182
+ }
183
+
184
+ func (c *wireCapture) Do(_ context.Context, call onexv1.Call, _ any) error {
185
+ c.calls = append(c.calls, call)
186
+ return c.err
187
+ }
188
+
189
+ func TestClientConstructionPathAndOperationBoundaries(t *testing.T) {
190
+ _, err := NewClient(nil, time.Second)
191
+ require.Error(t, err)
192
+ _, err = NewClient(&wireCapture{}, 0)
193
+ require.Error(t, err)
194
+ upstream := &onexv1.Error{Kind: onexv1.ErrorKindUnavailable, Code: "onex_dependency_unavailable"}
195
+ transport := &wireCapture{err: upstream}
196
+ client, err := NewClient(transport, 3*time.Second)
197
+ require.NoError(t, err)
198
+ for _, number := range []string{"", "..", "../inm_sample", "inm_sample/other", "inm_%2f", "inm_a?b", "inm_a\nb"} {
199
+ _, err := client.GetMessage(t.Context(), 42, number)
200
+ var typed *onexv1.Error
201
+ require.ErrorAs(t, err, &typed)
202
+ require.Equal(t, onexv1.ErrorKindValidation, typed.Kind)
203
+ }
204
+ require.Empty(t, transport.calls)
205
+ _, err = client.QueryInbox(t.Context(), InboxQuery{UserID: 42, TargetScope: "any"})
206
+ require.True(t, errors.Is(err, upstream))
207
+ require.Equal(t, "native_write", transport.calls[0].Operation)
208
+ require.Equal(t, "notification", transport.calls[0].Dependency)
209
+ require.Equal(t, 3*time.Second, transport.calls[0].Timeout)
210
+ _, err = client.AcknowledgeMessages(t.Context(), AcknowledgeMessagesRequest{UserID: 42})
211
+ require.ErrorIs(t, err, upstream)
212
+ require.Equal(t, "native_write", transport.calls[1].Operation)
213
+ _, err = client.StartDispatch(t.Context(), DispatchStartRequest{})
214
+ require.ErrorIs(t, err, upstream)
215
+ require.Equal(t, "dispatch_command_start", transport.calls[2].Operation)
216
+ _, err = client.AdvanceSource(t.Context(), SourceAdvanceRequest{})
217
+ require.ErrorIs(t, err, upstream)
218
+ require.Equal(t, "advance_source_state", transport.calls[3].Operation)
219
+ _, err = client.GetMessage(t.Context(), 42, "inm_sample")
220
+ require.ErrorIs(t, err, upstream)
221
+ require.Equal(t, "native_read", transport.calls[4].Operation)
222
+ }